Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions internal/fastsync/bundle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Package fastsync holds the ION fast-sync overlay's verifiable anchor bundles
// (epic #111). A Bundle is a self-contained, PoW-verifiable record of one ION anchor:
// the raw anchoring transaction plus a merkle branch proving it is included at a
// known index in a known block. A fast-sync peer is untrusted, but a Bundle verified
// against the receiving node's own header merkle root makes "this anchor exists at
// block H, index I" impossible to forge — the trustless core of fast-sync. (A peer
// can still omit bundles; that is handled by trust-then-verify, not here.)
package fastsync

import (
"bytes"
"encoding/binary"
"fmt"

"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"

"github.com/13x-tech/ion-node/internal/chain"
)

// Bundle is the verifiable proof-of-inclusion for one anchoring transaction.
type Bundle struct {
Height int32 // confirming block height (selects the header merkle root)
TxIndex int // the tx's position in that block
RawTx []byte // non-witness serialization; txid = double-SHA256(RawTx)
Branch []chainhash.Hash // merkle branch, bottom level first
}

// Verify recomputes the txid from RawTx and checks the merkle branch against
// merkleRoot (the confirming block header's merkle root, which the caller has
// PoW-verified). It returns the proven txid and whether the proof holds. A caller
// MUST source merkleRoot from its own verified header chain — never from the peer
// that supplied the Bundle — and should then apply its own validity rules to the tx.
func (b Bundle) Verify(merkleRoot chainhash.Hash) (chainhash.Hash, bool) {
var tx wire.MsgTx
if err := tx.DeserializeNoWitness(bytes.NewReader(b.RawTx)); err != nil {
return chainhash.Hash{}, false
}
// Reject trailing garbage: the non-witness serialization must consume RawTx
// exactly (a sloppy parse must not be passed off as a valid tx).
var reser bytes.Buffer
if err := tx.SerializeNoWitness(&reser); err != nil || !bytes.Equal(reser.Bytes(), b.RawTx) {
return chainhash.Hash{}, false
}
txid := tx.TxHash() // double-SHA256 of the non-witness serialization
if !chain.VerifyMerkleProof(txid, b.TxIndex, b.Branch, merkleRoot) {
return chainhash.Hash{}, false
}
return txid, true
}

// Tx deserializes the bundled transaction (non-witness). Callers use it to read the
// OP_RETURN anchor and the first input (for writer derivation).
func (b Bundle) Tx() (*wire.MsgTx, error) {
var tx wire.MsgTx
if err := tx.DeserializeNoWitness(bytes.NewReader(b.RawTx)); err != nil {
return nil, fmt.Errorf("fastsync: bundle tx: %w", err)
}
return &tx, nil
}

// encode serializes a Bundle for storage / the wire:
//
// height(4 BE) | txIndex(4 BE) | branchLen(4 BE) | branch(32*n) | rawTx(rest)
func (b Bundle) encode() []byte {
out := make([]byte, 12, 12+chainhash.HashSize*len(b.Branch)+len(b.RawTx))
binary.BigEndian.PutUint32(out[0:4], uint32(b.Height))
binary.BigEndian.PutUint32(out[4:8], uint32(b.TxIndex))
binary.BigEndian.PutUint32(out[8:12], uint32(len(b.Branch)))
for _, h := range b.Branch {
out = append(out, h[:]...)
}
return append(out, b.RawTx...)
}

func decodeBundle(data []byte) (Bundle, error) {
if len(data) < 12 {
return Bundle{}, fmt.Errorf("fastsync: short bundle (%d bytes)", len(data))
}
height := int32(binary.BigEndian.Uint32(data[0:4]))
txIndex := int(binary.BigEndian.Uint32(data[4:8]))
nBranch := int(binary.BigEndian.Uint32(data[8:12]))
off := 12
if nBranch < 0 || nBranch > (len(data)-off)/chainhash.HashSize {
return Bundle{}, fmt.Errorf("fastsync: bundle branch length %d exceeds payload", nBranch)
}
branch := make([]chainhash.Hash, nBranch)
for i := 0; i < nBranch; i++ {
copy(branch[i][:], data[off:off+chainhash.HashSize])
off += chainhash.HashSize
}
raw := append([]byte(nil), data[off:]...)
return Bundle{Height: height, TxIndex: txIndex, Branch: branch, RawTx: raw}, nil
}
98 changes: 98 additions & 0 deletions internal/fastsync/bundle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package fastsync

import (
"bytes"
"testing"

"github.com/btcsuite/btcd/blockchain"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"

"github.com/13x-tech/ion-node/internal/chain"
)

func mkTx(i int) *wire.MsgTx {
mt := wire.NewMsgTx(1)
mt.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{Index: uint32(i)},
SignatureScript: []byte{byte(i), byte(i >> 8), 0x51},
})
mt.AddTxOut(wire.NewTxOut(int64(i+1), []byte{txscript.OP_TRUE}))
return mt
}

// TestBundleVerify builds a real block tree, makes a Bundle for one tx, and confirms
// it verifies against the block's real (CalcMerkleRoot) merkle root — and that a
// tampered branch, wrong root, or trailing garbage in the raw tx all fail.
func TestBundleVerify(t *testing.T) {
const n, idx = 7, 3
txs := make([]*btcutil.Tx, n)
ids := make([]chainhash.Hash, n)
for i := 0; i < n; i++ {
txs[i] = btcutil.NewTx(mkTx(i))
ids[i] = *txs[i].Hash()
}
root := blockchain.CalcMerkleRoot(txs, false)
branch, err := chain.MerkleProof(ids, idx)
if err != nil {
t.Fatal(err)
}
var raw bytes.Buffer
if err := mkTx(idx).SerializeNoWitness(&raw); err != nil {
t.Fatal(err)
}
b := Bundle{Height: 700000, TxIndex: idx, RawTx: raw.Bytes(), Branch: branch}

txid, ok := b.Verify(root)
if !ok {
t.Fatal("a valid bundle must verify against the block merkle root")
}
if txid != ids[idx] {
t.Errorf("verified txid = %s, want %s", txid, ids[idx])
}

tampered := Bundle{Height: b.Height, TxIndex: idx, RawTx: raw.Bytes(), Branch: append([]chainhash.Hash(nil), branch...)}
tampered.Branch[0][0] ^= 0xff
if _, ok := tampered.Verify(root); ok {
t.Error("a tampered branch must not verify")
}
var wrongRoot chainhash.Hash
wrongRoot[0] = 0x01
if _, ok := b.Verify(wrongRoot); ok {
t.Error("the wrong merkle root must not verify")
}
garbage := Bundle{Height: b.Height, TxIndex: idx, RawTx: append(raw.Bytes(), 0xde, 0xad), Branch: branch}
if _, ok := garbage.Verify(root); ok {
t.Error("trailing garbage in RawTx must not verify (sloppy parse rejected)")
}
}

func TestBundleEncodeRoundTrip(t *testing.T) {
b := Bundle{
Height: 952901,
TxIndex: 5,
RawTx: []byte{0x01, 0x02, 0x03, 0x04},
Branch: []chainhash.Hash{{0xaa, 0x01}, {0xbb, 0x02}},
}
got, err := decodeBundle(b.encode())
if err != nil {
t.Fatalf("decode: %v", err)
}
if got.Height != b.Height || got.TxIndex != b.TxIndex || !bytes.Equal(got.RawTx, b.RawTx) {
t.Errorf("round-trip header mismatch: %+v vs %+v", got, b)
}
if len(got.Branch) != 2 || got.Branch[0] != b.Branch[0] || got.Branch[1] != b.Branch[1] {
t.Errorf("round-trip branch mismatch: %v vs %v", got.Branch, b.Branch)
}
if _, err := decodeBundle([]byte{0x00, 0x01}); err == nil {
t.Error("a short bundle must error, not panic")
}
// A branch length claiming more hashes than the payload holds must error.
bad := make([]byte, 12)
bad[11] = 0xff // claim 255 branch hashes with no payload
if _, err := decodeBundle(bad); err == nil {
t.Error("an over-long branch length must error")
}
}
93 changes: 93 additions & 0 deletions internal/fastsync/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package fastsync

import (
"encoding/binary"
"fmt"

bolt "go.etcd.io/bbolt"
)

var bucketBundles = []byte("bundles")

// Store is a persistent map from an anchor's transaction number (height<<32|txIndex)
// to its verifiable Bundle. A node that opts into serving fast-sync writes bundles
// here at scan time (when it still has the full block to build the merkle branch),
// so it can later serve PoW-verifiable proofs without retaining or re-fetching full
// blocks. It is auxiliary serving data, kept in its own file (like the txid index),
// so a node that does not serve fast-sync incurs no cost.
type Store struct {
db *bolt.DB
}

// Open opens (creating if needed) the bundle store at path.
func Open(path string) (*Store, error) {
db, err := bolt.Open(path, 0o600, nil)
if err != nil {
return nil, fmt.Errorf("fastsync: open %s: %w", path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, e := tx.CreateBucketIfNotExists(bucketBundles)
return e
}); err != nil {
_ = db.Close()
return nil, fmt.Errorf("fastsync: init bucket: %w", err)
}
return &Store{db: db}, nil
}

func txnumKey(txnum uint64) []byte {
k := make([]byte, 8)
binary.BigEndian.PutUint64(k, txnum)
return k
}

// Put stores b under txnum (idempotent — re-indexing a height overwrites, matching
// the txid index's self-healing-after-reorg behavior).
func (s *Store) Put(txnum uint64, b Bundle) error {
return s.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(bucketBundles).Put(txnumKey(txnum), b.encode())
})
}

// Get returns the bundle for txnum, ok=false if absent.
func (s *Store) Get(txnum uint64) (Bundle, bool, error) {
var (
b Bundle
found bool
)
err := s.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketBundles).Get(txnumKey(txnum))
if v == nil {
return nil
}
decoded, derr := decodeBundle(v)
if derr != nil {
return derr
}
b, found = decoded, true
return nil
})
return b, found, err
}

// Range invokes fn for every bundle with txnum in [fromTxnum, toTxnum], in ascending
// txnum order, stopping early if fn returns false. Used to serve a height range.
func (s *Store) Range(fromTxnum, toTxnum uint64, fn func(txnum uint64, b Bundle) bool) error {
return s.db.View(func(tx *bolt.Tx) error {
c := tx.Bucket(bucketBundles).Cursor()
lo, hi := txnumKey(fromTxnum), txnumKey(toTxnum)
for k, v := c.Seek(lo); k != nil && string(k) <= string(hi); k, v = c.Next() {
b, derr := decodeBundle(v)
if derr != nil {
return derr
}
if !fn(binary.BigEndian.Uint64(k), b) {
return nil
}
}
return nil
})
}

// Close releases the underlying database.
func (s *Store) Close() error { return s.db.Close() }
56 changes: 56 additions & 0 deletions internal/fastsync/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package fastsync

import (
"path/filepath"
"testing"

"github.com/btcsuite/btcd/chaincfg/chainhash"
)

func TestStorePutGetRange(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "bundles.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })

mk := func(h int32, ti int) (uint64, Bundle) {
txnum := uint64(uint32(h))<<32 | uint64(uint32(ti))
return txnum, Bundle{Height: h, TxIndex: ti, RawTx: []byte{byte(h), byte(ti)}, Branch: []chainhash.Hash{{byte(h)}}}
}

n1, b1 := mk(100, 0)
n2, b2 := mk(100, 2)
n3, b3 := mk(105, 1)
for _, e := range []struct {
n uint64
b Bundle
}{{n1, b1}, {n2, b2}, {n3, b3}} {
if err := s.Put(e.n, e.b); err != nil {
t.Fatalf("Put: %v", err)
}
}

got, ok, err := s.Get(n2)
if err != nil || !ok {
t.Fatalf("Get(n2): ok=%v err=%v", ok, err)
}
if got.Height != 100 || got.TxIndex != 2 {
t.Errorf("Get(n2) = %+v, want height 100 txIndex 2", got)
}
if _, ok, _ := s.Get(uint64(1) << 40); ok {
t.Error("a missing txnum must return ok=false")
}

// Range over height 100 only (txnum [100<<32, 101<<32)).
var seen []int
if err := s.Range(uint64(100)<<32, uint64(101)<<32-1, func(_ uint64, b Bundle) bool {
seen = append(seen, b.TxIndex)
return true
}); err != nil {
t.Fatalf("Range: %v", err)
}
if len(seen) != 2 || seen[0] != 0 || seen[1] != 2 {
t.Errorf("Range over height 100 = %v, want [0 2] in order", seen)
}
}
Loading
Loading