package domain import ( "crypto/sha256" "encoding/binary" "testing" "git.smesh.lol/nostrino/pkg/chaindata" ) // buildBlock assembles a one-transaction block that pays script, with a header whose merkle // root field is the transaction's own id. // // It is not a real block — there is no proof of work — but it is a well-formed one, which is // what pkg/block checks: the merkle root is the commitment the domain verifies, and it is // computed here rather than taken on trust. func buildBlock(t *testing.T, script []byte, value uint64) []byte { t.Helper() // One transaction: version, one input spending a made-up outpoint, one output, locktime. tx := make([]byte, 0, 128) tx = binary.LittleEndian.AppendUint32(tx, 2) tx = append(tx, 0x01) tx = append(tx, make([]byte, 32)...) tx = binary.LittleEndian.AppendUint32(tx, 0) tx = append(tx, 0x00) tx = binary.LittleEndian.AppendUint32(tx, 0xffffffff) tx = append(tx, 0x01) tx = binary.LittleEndian.AppendUint64(tx, value) tx = append(tx, compactSize(len(script))...) tx = append(tx, script...) tx = binary.LittleEndian.AppendUint32(tx, 0) first := sha256.Sum256(tx) txid := sha256.Sum256(first[:]) header := make([]byte, 80) binary.LittleEndian.PutUint32(header[0:4], 1) copy(header[36:68], txid[:]) out := append([]byte{}, header...) out = append(out, 0x01) out = append(out, tx...) return out } // compactSize writes a length prefix. func compactSize(value int) []byte { if value < 0xfd { return []byte{byte(value)} } return []byte{0xfd, byte(value), byte(value >> 8)} } // TestSyncTurnsAMatchIntoACoin: the whole path — a matched height, the block it names, the // output that pays the wallet, and a coin that survives as a coin. func TestSyncTurnsAMatchIntoACoin(t *testing.T) { r := newRig(t) created, err := r.domain.Restore("btc", phrase, "pass") if err != nil { t.Fatal(err) } address, err := r.domain.Addresses("btc", false, 0, 1) if err != nil { t.Fatal(err) } script := address[0].Script() chain := r.chains["btc"] chain.syncTo = chaindataPosition(200) chain.matched = []uint32{150} chain.blocks = map[uint32][]byte{150: buildBlock(t, script, 70000)} report, err := r.domain.Sync("btc", 0) if err != nil { t.Fatal(err) } if len(report.New) != 1 || report.New[0] != 150 { t.Fatalf("new matches are %v", report.New) } coins, err := r.domain.Unspent() if err != nil { t.Fatal(err) } if len(coins) != 1 { t.Fatalf("%d coins", len(coins)) } // The coin pays the wallet's own script: the address we just derived, and no other. if coins[0].Value != 70000 || coins[0].Vout != 0 || coins[0].Height != 150 { t.Fatalf("the coin is %+v", coins[0]) } if showAddress(t, created.Address.Path.String()) == "" { t.Fatal("the created address has no path") } if chain.blockCalls != 1 { t.Fatalf("%d block fetches", chain.blockCalls) } // A second sync does not fetch the block again. report, err = r.domain.Sync("btc", 0) if err != nil { t.Fatal(err) } if len(report.New) != 0 { t.Fatalf("the second sync found %v", report.New) } if chain.blockCalls != 1 { t.Fatalf("the block was fetched %d times", chain.blockCalls) } } // TestACorruptBlockLeavesTheHeightUnscanned: a block that fails its merkle check must not mark // the height as done, or the coins it should have produced are lost silently. func TestACorruptBlockLeavesTheHeightUnscanned(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } addresses, err := r.domain.Addresses("btc", false, 0, 1) if err != nil { t.Fatal(err) } raw := buildBlock(t, addresses[0].Script(), 50000) raw[81] ^= 0xff chain := r.chains["btc"] chain.syncTo = chaindataPosition(10) chain.matched = []uint32{9} chain.blocks = map[uint32][]byte{9: raw} if _, err := r.domain.Sync("btc", 0); err == nil { t.Fatal("a corrupt block was accepted") } if len(r.domain.Matches("btc")) != 0 { t.Fatal("the height was marked as scanned") } // The next sync retries the height, and a good block produces the coin. chain.blocks[9] = buildBlock(t, addresses[0].Script(), 50000) if _, err := r.domain.Sync("btc", 0); err != nil { t.Fatal(err) } coins, err := r.domain.Unspent() if err != nil { t.Fatal(err) } if len(coins) != 1 || coins[0].Value != 50000 { t.Fatalf("the retry produced %+v", coins) } } // TestAValidBlockThatPaysNobodyIsStillScanned: the marker records that the block was checked, // not that it paid, so a later sync does not download it again. func TestAValidBlockThatPaysNobodyIsStillScanned(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } chain := r.chains["btc"] chain.syncTo = chaindataPosition(5) chain.matched = []uint32{4} chain.blocks = map[uint32][]byte{4: buildBlock(t, []byte{0x00, 0x14, 0x99}, 1000)} if _, err := r.domain.Sync("btc", 0); err != nil { t.Fatal(err) } if got := r.domain.Matches("btc"); len(got) != 1 || got[0] != 4 { t.Fatalf("the recorded heights are %v", got) } coins, err := r.domain.Unspent() if err != nil { t.Fatal(err) } if len(coins) != 0 { t.Fatalf("%d coins for a block that pays somebody else", len(coins)) } chain.blockCalls = 0 if _, err := r.domain.Sync("btc", 0); err != nil { t.Fatal(err) } if chain.blockCalls != 0 { t.Fatalf("a scanned height was fetched again %d times", chain.blockCalls) } } // showAddress is a tiny helper so the created address's path is asserted somewhere. func showAddress(t *testing.T, path string) string { t.Helper() return path } // chaindataPosition is the position a fake chain reports after a sync. func chaindataPosition(tip uint32) chaindata.Position { return chaindata.Position{Tip: tip, Filters: tip} }