platformvm/airdrop: ZAP-native claims persistence

The airdrop manager wrote the full claims map (keyed on Ethereum
address) to the database via json/v2 on every claim. Migrate to a
deterministic ZAP envelope: addresses sorted lexicographically, big.Int
amounts emitted as raw big-endian bytes with a u32 length prefix. New
codec lives in vms/platformvm/airdrop/codec_zap.go; schema at
proto/schemas/airdrop/airdrop.zap.

Hard fork — airdrop state already inside the LP-023 reset window.
This commit is contained in:
Hanzo AI
2026-06-06 23:14:29 -07:00
parent c5b1f61176
commit 82c96ef078
2 changed files with 189 additions and 12 deletions
+9 -12
View File
@@ -5,7 +5,6 @@ package airdrop
import (
"context"
"github.com/go-json-experiment/json"
"errors"
"math/big"
"sync"
@@ -233,9 +232,8 @@ func (m *Manager) loadSnapshot() error {
return nil
}
// loadClaims loads existing claims from the database
// loadClaims loads existing claims from the database (ZAP-encoded).
func (m *Manager) loadClaims() error {
// Load claims from database
claimsData, err := m.db.Get([]byte("airdrop_claims"))
if err != nil {
if err == database.ErrNotFound {
@@ -244,8 +242,8 @@ func (m *Manager) loadClaims() error {
return err
}
var claims map[common.Address]*AirdropClaim
if err := json.Unmarshal(claimsData, &claims); err != nil {
claims, err := decodeClaims(claimsData)
if err != nil {
return err
}
@@ -253,14 +251,13 @@ func (m *Manager) loadClaims() error {
return nil
}
// saveClaim saves a claim to the database
// saveClaim persists the full claims map to the database (ZAP-encoded).
// The argument is retained for call-site compatibility but unused — the
// receiver writes the entire in-memory map, the same semantics as the
// legacy JSON path.
func (m *Manager) saveClaim(claim *AirdropClaim) error {
claimsData, err := json.Marshal(m.claims)
if err != nil {
return err
}
return m.db.Put([]byte("airdrop_claims"), claimsData)
_ = claim
return m.db.Put([]byte("airdrop_claims"), encodeClaims(m.claims))
}
// verifySignature verifies ownership of the Ethereum address
+180
View File
@@ -0,0 +1,180 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package airdrop
// ZAP-native codec for the REQL→LUX airdrop claims map.
//
// The legacy persistence path serialized a map[common.Address]*AirdropClaim
// via json/v2. That format isn't deterministic across implementations
// (map iteration order, big.Int formatting) and ties the on-disk shape to
// json/v2 — which we are ripping out of the internal data plane.
//
// Wire shape (single ZAP envelope, kind byte = airdropClaimsKind):
//
// Kind u8 @ 0 # = airdropClaimsKind
// _pad u8x3 @ 1
// Count u32 @ 4
// Entries bytes @ 8 # concatenated ClaimEntry records (each var-len)
//
// Each ClaimEntry record (variable-tail, length-prefixed):
//
// u32 entryLen
// u8 entryKind # = entryKindClaim
// 20B EthAddress ( @ 0..20 of payload )
// 20B LuxAddress (ShortID) ( @ 20..40 )
// 32B TxID ( @ 40..72 )
// i64 ClaimUnixNs ( @ 72..80 )
// i64 VestingEndUnixNs ( @ 80..88 )
// u32 amountLen ( @ 88..92 ) big.Int big-endian bytes
// N amount bytes ( @ 92..)
//
// Hard fork: airdrop state is persisted under a single db key and was
// part of the LP-023 reset window; existing dev clusters already
// re-bootstrapped with no prior claims.
import (
"encoding/binary"
"errors"
"fmt"
"math/big"
"sort"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
const (
airdropClaimsKind uint8 = 1
offEnvKind = 0
offEnvCount = 4
offEnvEntries = 8
sizeAirdropEnvelope = 16
airdropEntryKindClaim uint8 = 1
claimOffEthAddress = 0
claimOffLuxAddress = 20
claimOffTxID = 40
claimOffClaimUnixNs = 72
claimOffVestingEndUnixNs = 80
claimOffAmountLen = 88
claimPayloadFixed = 92
)
// ErrCorruptAirdropBlob is returned when a parsed claims blob is
// internally inconsistent (wrong kind, payload too short, etc.).
var ErrCorruptAirdropBlob = errors.New("airdrop: corrupt claims blob")
// encodeClaims serializes a claims map to a ZAP envelope. Claims are
// emitted in lexicographic order of the eth address bytes so the
// encoding is deterministic.
func encodeClaims(claims map[common.Address]*AirdropClaim) []byte {
addrs := make([]common.Address, 0, len(claims))
for a := range claims {
addrs = append(addrs, a)
}
sort.Slice(addrs, func(i, j int) bool {
return string(addrs[i][:]) < string(addrs[j][:])
})
var entries []byte
for _, a := range addrs {
entries = append(entries, encodeClaimEntry(claims[a])...)
}
b := zap.NewBuilder(sizeAirdropEnvelope + len(entries) + 32)
ob := b.StartObject(sizeAirdropEnvelope)
ob.SetUint8(offEnvKind, airdropClaimsKind)
ob.SetUint32(offEnvCount, uint32(len(addrs)))
ob.SetBytes(offEnvEntries, entries)
ob.FinishAsRoot()
return b.Finish()
}
// decodeClaims parses a ZAP envelope into a claims map.
func decodeClaims(data []byte) (map[common.Address]*AirdropClaim, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
root := msg.Root()
if root.Uint8(offEnvKind) != airdropClaimsKind {
return nil, fmt.Errorf("%w: bad kind", ErrCorruptAirdropBlob)
}
count := root.Uint32(offEnvCount)
entries := root.Bytes(offEnvEntries)
out := make(map[common.Address]*AirdropClaim, count)
for i := uint32(0); i < count; i++ {
if len(entries) < 5 {
return nil, fmt.Errorf("%w: entry header at %d", ErrCorruptAirdropBlob, i)
}
entryLen := binary.LittleEndian.Uint32(entries[:4])
if uint32(len(entries)) < 4+entryLen {
return nil, fmt.Errorf("%w: entry %d truncated", ErrCorruptAirdropBlob, i)
}
kind := entries[4]
payload := entries[5 : 4+entryLen]
entries = entries[4+entryLen:]
if kind != airdropEntryKindClaim {
return nil, fmt.Errorf("%w: unknown entry kind 0x%02x", ErrCorruptAirdropBlob, kind)
}
claim, addr, err := decodeClaimEntry(payload)
if err != nil {
return nil, err
}
out[addr] = claim
}
if len(entries) != 0 {
return nil, fmt.Errorf("%w: %d trailing bytes", ErrCorruptAirdropBlob, len(entries))
}
return out, nil
}
func encodeClaimEntry(c *AirdropClaim) []byte {
var amountBytes []byte
if c.AmountClaimed != nil {
amountBytes = c.AmountClaimed.Bytes()
}
payloadLen := claimPayloadFixed + len(amountBytes)
out := make([]byte, 5+payloadLen)
binary.LittleEndian.PutUint32(out[0:4], uint32(1+payloadLen))
out[4] = airdropEntryKindClaim
p := out[5:]
copy(p[claimOffEthAddress:claimOffEthAddress+20], c.Address[:])
copy(p[claimOffLuxAddress:claimOffLuxAddress+20], c.LuxAddress[:])
copy(p[claimOffTxID:claimOffTxID+32], c.TxID[:])
binary.LittleEndian.PutUint64(p[claimOffClaimUnixNs:], uint64(c.ClaimTime.UnixNano()))
binary.LittleEndian.PutUint64(p[claimOffVestingEndUnixNs:], uint64(c.VestingEndTime.UnixNano()))
binary.LittleEndian.PutUint32(p[claimOffAmountLen:], uint32(len(amountBytes)))
copy(p[claimPayloadFixed:], amountBytes)
return out
}
func decodeClaimEntry(p []byte) (*AirdropClaim, common.Address, error) {
if len(p) < claimPayloadFixed {
return nil, common.Address{}, fmt.Errorf("%w: claim payload %d < %d", ErrCorruptAirdropBlob, len(p), claimPayloadFixed)
}
amountLen := binary.LittleEndian.Uint32(p[claimOffAmountLen:])
if uint32(len(p)) < uint32(claimPayloadFixed)+amountLen {
return nil, common.Address{}, fmt.Errorf("%w: claim amount tail %d < %d", ErrCorruptAirdropBlob, len(p), uint32(claimPayloadFixed)+amountLen)
}
c := &AirdropClaim{
ClaimTime: time.Unix(0, int64(binary.LittleEndian.Uint64(p[claimOffClaimUnixNs:]))),
VestingEndTime: time.Unix(0, int64(binary.LittleEndian.Uint64(p[claimOffVestingEndUnixNs:]))),
AmountClaimed: new(big.Int).SetBytes(p[claimPayloadFixed : uint32(claimPayloadFixed)+amountLen]),
}
copy(c.Address[:], p[claimOffEthAddress:claimOffEthAddress+20])
copy(c.LuxAddress[:], p[claimOffLuxAddress:claimOffLuxAddress+20])
var tx ids.ID
copy(tx[:], p[claimOffTxID:claimOffTxID+32])
c.TxID = tx
return c, c.Address, nil
}