From eaaa398c34225c825b2fd204130e593f5c0dacdf Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 04:27:40 +0000 Subject: [PATCH] Add coinbase airdrop proof validation --- pkg/covenant/airdrop_proof.go | 358 +++++++++++++++++++++++++++++++ pkg/covenant/names.go | 5 + pkg/covenant/rules_extra_test.go | 100 ++++++++- pkg/covenant/verify.go | 35 ++- 4 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 pkg/covenant/airdrop_proof.go diff --git a/pkg/covenant/airdrop_proof.go b/pkg/covenant/airdrop_proof.go new file mode 100644 index 0000000..7bd52a9 --- /dev/null +++ b/pkg/covenant/airdrop_proof.go @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "encoding/binary" + + core "dappco.re/go/core" +) + +const ( + airdropReward = 4246994314 + airdropLeaves = 216199 + airdropDepth = 18 + airdropSubdepth = 3 + airdropSubleaves = 8 + faucetDepth = 11 + faucetLeaves = 1358 + maxAirdropProofLen = 3400 + airdropKeyAddress = 4 +) + +type airdropProof struct { + index uint32 + proof [][]byte + subindex uint8 + subproof [][]byte + key []byte + version uint8 + address []byte + fee uint64 + signature []byte +} + +type airdropKey struct { + version uint8 + address []byte + value uint64 +} + +func decodeAirdropProof(data []byte) (*airdropProof, error) { + if len(data) > maxAirdropProofLen { + return nil, core.E("covenant.decodeAirdropProof", "proof too large", nil) + } + + proof := &airdropProof{} + var n int + var err error + + if proof.index, n, err = readProofU32(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode index", err) + } + data = data[n:] + + var count uint8 + if count, n, err = readProofU8(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode proof length", err) + } + data = data[n:] + + if int(count) > airdropDepth { + return nil, core.E("covenant.decodeAirdropProof", "invalid proof depth", nil) + } + + proof.proof = make([][]byte, 0, count) + for i := 0; i < int(count); i++ { + var hash []byte + if hash, n, err = readProofBytes(data, 32); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode proof hash", err) + } + data = data[n:] + proof.proof = append(proof.proof, hash) + } + + if proof.subindex, n, err = readProofU8(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode subindex", err) + } + data = data[n:] + + var total uint8 + if total, n, err = readProofU8(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode subproof length", err) + } + data = data[n:] + + if int(total) > airdropSubdepth { + return nil, core.E("covenant.decodeAirdropProof", "invalid subproof depth", nil) + } + + proof.subproof = make([][]byte, 0, total) + for i := 0; i < int(total); i++ { + var hash []byte + if hash, n, err = readProofBytes(data, 32); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode subproof hash", err) + } + data = data[n:] + proof.subproof = append(proof.subproof, hash) + } + + if proof.key, n, err = readProofVarBytes(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode key", err) + } + data = data[n:] + + if len(proof.key) == 0 { + return nil, core.E("covenant.decodeAirdropProof", "missing key", nil) + } + + if proof.version, n, err = readProofU8(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode version", err) + } + data = data[n:] + + var size uint8 + if size, n, err = readProofU8(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode address length", err) + } + data = data[n:] + + if size < 2 || size > 40 { + return nil, core.E("covenant.decodeAirdropProof", "invalid address length", nil) + } + + if proof.address, n, err = readProofBytes(data, int(size)); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode address", err) + } + data = data[n:] + + if proof.fee, n, err = readProofVarint(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode fee", err) + } + data = data[n:] + + if proof.signature, n, err = readProofVarBytes(data); err != nil { + return nil, core.E("covenant.decodeAirdropProof", "failed to decode signature", err) + } + data = data[n:] + + if len(data) != 0 { + return nil, core.E("covenant.decodeAirdropProof", "trailing data", nil) + } + + return proof, nil +} + +func (p *airdropProof) isAddress() bool { + return len(p.key) > 0 && p.key[0] == airdropKeyAddress +} + +func (p *airdropProof) getValue() uint64 { + if !p.isAddress() { + return airdropReward + } + + key, ok := p.getKey() + if !ok { + return 0 + } + + return key.value +} + +func (p *airdropProof) getKey() (airdropKey, bool) { + if !p.isAddress() { + return airdropKey{}, false + } + + if len(p.key) < 1+1+1+8+1 { + return airdropKey{}, false + } + + key := airdropKey{} + key.version = p.key[1] + + addrLen := int(p.key[2]) + if addrLen < 2 || addrLen > 40 { + return airdropKey{}, false + } + + want := 1 + 1 + 1 + addrLen + 8 + 1 + if len(p.key) != want { + return airdropKey{}, false + } + + key.address = append([]byte(nil), p.key[3:3+addrLen]...) + key.value = binary.LittleEndian.Uint64(p.key[3+addrLen : 3+addrLen+8]) + return key, true +} + +func (p *airdropProof) isSane() bool { + if len(p.key) == 0 { + return false + } + + if p.version > 31 { + return false + } + + if len(p.address) < 2 || len(p.address) > 40 { + return false + } + + if p.isAddress() { + if _, ok := p.getKey(); !ok { + return false + } + } + + value := p.getValue() + + if p.fee > value { + return false + } + + if p.isAddress() { + if len(p.subproof) != 0 { + return false + } + + if p.subindex != 0 { + return false + } + + if len(p.proof) > faucetDepth { + return false + } + + if p.index >= faucetLeaves { + return false + } + + return true + } + + if len(p.subproof) > airdropSubdepth { + return false + } + + if p.subindex >= airdropSubleaves { + return false + } + + if len(p.proof) > airdropDepth { + return false + } + + if p.index >= airdropLeaves { + return false + } + + if p.size() > maxAirdropProofLen { + return false + } + + return true +} + +func (p *airdropProof) size() int { + size := 0 + size += 4 + size += 1 + size += len(p.proof) * 32 + size += 1 + size += 1 + size += len(p.subproof) * 32 + size += sizeProofVarBytes(len(p.key)) + size += 1 + size += 1 + size += len(p.address) + size += sizeProofVarint(p.fee) + size += sizeProofVarBytes(len(p.signature)) + return size +} + +func readProofU8(src []byte) (uint8, int, error) { + if len(src) < 1 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + + return src[0], 1, nil +} + +func readProofU32(src []byte) (uint32, int, error) { + if len(src) < 4 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + + return binary.LittleEndian.Uint32(src[:4]), 4, nil +} + +func readProofBytes(src []byte, n int) ([]byte, int, error) { + if n < 0 || len(src) < n { + return nil, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + + buf := make([]byte, n) + copy(buf, src[:n]) + return buf, n, nil +} + +func readProofVarint(src []byte) (uint64, int, error) { + if len(src) == 0 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + + switch prefix := src[0]; { + case prefix < 0xfd: + return uint64(prefix), 1, nil + case prefix == 0xfd: + if len(src) < 3 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + return uint64(binary.LittleEndian.Uint16(src[1:3])), 3, nil + case prefix == 0xfe: + if len(src) < 5 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + return uint64(binary.LittleEndian.Uint32(src[1:5])), 5, nil + default: + if len(src) < 9 { + return 0, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + return binary.LittleEndian.Uint64(src[1:9]), 9, nil + } +} + +func readProofVarBytes(src []byte) ([]byte, int, error) { + n, consumed, err := readProofVarint(src) + if err != nil { + return nil, 0, err + } + + if uint64(len(src[consumed:])) < n { + return nil, 0, core.E("covenant.decodeAirdropProof", "short buffer", nil) + } + + end := consumed + int(n) + buf := make([]byte, int(n)) + copy(buf, src[consumed:end]) + return buf, end, nil +} + +func sizeProofVarint(n uint64) int { + switch { + case n < 0xfd: + return 1 + case n <= 0xffff: + return 3 + case n <= 0xffffffff: + return 5 + default: + return 9 + } +} + +func sizeProofVarBytes(n int) int { + return sizeProofVarint(uint64(n)) + n +} diff --git a/pkg/covenant/names.go b/pkg/covenant/names.go index 3c4094e..2ffa02e 100644 --- a/pkg/covenant/names.go +++ b/pkg/covenant/names.go @@ -148,6 +148,11 @@ func HasSaneCovenants(tx primitives.Transaction) bool { if len(input.Witness) != 1 { return false } + + proof, err := decodeAirdropProof(input.Witness[0]) + if err != nil || !proof.isSane() { + return false + } } case TypeClaim: diff --git a/pkg/covenant/rules_extra_test.go b/pkg/covenant/rules_extra_test.go index 49cf842..4875d54 100644 --- a/pkg/covenant/rules_extra_test.go +++ b/pkg/covenant/rules_extra_test.go @@ -3,6 +3,7 @@ package covenant import ( + "bytes" "encoding/binary" "testing" @@ -170,7 +171,7 @@ func TestHasSaneCovenants(t *testing.T) { Outputs: []primitives.Output{ { Covenant: primitives.Covenant{ - Type: 99, + Type: 99, Items: make([][]byte, 256), }, }, @@ -487,3 +488,100 @@ func TestVerifyCovenantsCoinbaseLinkedOutputs(t *testing.T) { } }) } + +func encodeTestVarint(n uint64) []byte { + switch { + case n < 0xfd: + return []byte{byte(n)} + case n <= 0xffff: + return []byte{0xfd, byte(n), byte(n >> 8)} + case n <= 0xffffffff: + return []byte{0xfe, byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} + default: + return []byte{ + 0xff, + byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24), + byte(n >> 32), byte(n >> 40), byte(n >> 48), byte(n >> 56), + } + } +} + +func encodeTestAirdropKey(version uint8, address []byte, value uint64, sponsor bool) []byte { + key := []byte{4, version, byte(len(address))} + key = append(key, address...) + + var tmp [8]byte + binary.LittleEndian.PutUint64(tmp[:], value) + key = append(key, tmp[:]...) + if sponsor { + key = append(key, 1) + } else { + key = append(key, 0) + } + + return key +} + +func encodeTestAirdropProof(key, address []byte, fee uint64) []byte { + var out []byte + var index [4]byte + binary.LittleEndian.PutUint32(index[:], 3) + out = append(out, index[:]...) + out = append(out, 0) // proof length + out = append(out, 0) // subindex + out = append(out, 0) // subproof length + out = appendTestVarBytes(out, key) + out = append(out, 0) // version + out = append(out, byte(len(address))) + out = append(out, address...) + out = append(out, encodeTestVarint(fee)...) + out = appendTestVarBytes(out, nil) + return out +} + +func appendTestVarBytes(dst, src []byte) []byte { + dst = append(dst, encodeTestVarint(uint64(len(src)))...) + return append(dst, src...) +} + +func TestCoinbaseAirdropProofValidation(t *testing.T) { + address := bytes.Repeat([]byte{0x42}, 20) + key := encodeTestAirdropKey(0, address, 1000, false) + proof := encodeTestAirdropProof(key, address, 10) + + tx := primitives.Transaction{ + Inputs: []primitives.Input{ + {Prevout: primitives.NewOutpoint()}, + { + Prevout: primitives.Outpoint{TxHash: primitives.Hash{4}, Index: 0}, + Witness: [][]byte{proof}, + }, + }, + Outputs: []primitives.Output{ + {Covenant: primitives.Covenant{Type: uint8(TypeNone)}}, + { + Value: 990, + Address: primitives.Address{ + Version: 0, + Hash: append([]byte(nil), address...), + }, + Covenant: primitives.Covenant{Type: uint8(TypeNone)}, + }, + }, + } + + if !HasSaneCovenants(tx) { + t.Fatal("HasSaneCovenants should accept a valid coinbase airdrop proof") + } + + if got := VerifyCovenants(tx, testCoinView{coins: map[primitives.Outpoint]primitives.Output{}}, 100, Network{}); got != 0 { + t.Fatalf("VerifyCovenants returned %d for a valid coinbase airdrop proof, want 0", got) + } + + badTx := tx + badTx.Outputs[1].Value = 991 + + if got := VerifyCovenants(badTx, testCoinView{coins: map[primitives.Outpoint]primitives.Output{}}, 100, Network{}); got != -1 { + t.Fatalf("VerifyCovenants returned %d for a mismatched coinbase airdrop value, want -1", got) + } +} diff --git a/pkg/covenant/verify.go b/pkg/covenant/verify.go index 0236c4c..de6e7e5 100644 --- a/pkg/covenant/verify.go +++ b/pkg/covenant/verify.go @@ -33,15 +33,16 @@ type Network struct { // // The function mirrors the JS reference return convention: 0 on success and // -1 on failure. The Go port covers the covenant transitions that depend only -// on the transaction and coin view, and it conservatively validates the -// coinbase-linked covenant shape without decoding the higher-level proof -// formats that live in the wider blockchain stack. +// on the transaction and coin view, and it decodes the coinbase airdrop proof +// shape needed for the wire-level sanity checks implemented here. func VerifyCovenants(tx primitives.Transaction, view CoinView, height uint32, network Network) int { if !HasSaneCovenants(tx) { return -1 } if isCoinbaseTx(tx) { + var conjured uint64 + for i := 1; i < len(tx.Inputs); i++ { if i >= len(tx.Outputs) { return -1 @@ -57,7 +58,33 @@ func VerifyCovenants(tx primitives.Transaction, view CoinView, height uint32, ne switch cov.Type { case uint8(TypeNone): - continue + proof, err := decodeAirdropProof(input.Witness[0]) + if err != nil || !proof.isSane() { + return -1 + } + + value := proof.getValue() + if value < proof.fee { + return -1 + } + + if output.Value != value-proof.fee { + return -1 + } + + if output.Address.Version != proof.version { + return -1 + } + + if !bytes.Equal(output.Address.Hash, proof.address) { + return -1 + } + + if value > ^uint64(0)-conjured { + return -1 + } + + conjured += value case uint8(TypeClaim): blockHeight, err := cov.GetU32(1)