Add coinbase airdrop proof validation

This commit is contained in:
Virgil 2026-04-04 04:27:40 +00:00
parent 1aa20aecd4
commit eaaa398c34
4 changed files with 493 additions and 5 deletions

View file

@ -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
}

View file

@ -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:

View file

@ -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)
}
}

View file

@ -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)