// Package rigscan proves the whole path against a real node: a filter match becomes a verified // coin, with no wallet RPC anywhere in it. // // It needs the regtest rig, so it skips unless NOSTRINO_RIG=1. The node must serve BIP157 // (-blockfilterindex=1 and -peerblockfilters=1), must have the "rig" wallet loaded to fund the // derived address, and must be reachable over v1 transport (-v2transport=0 is the safe setting, // because this client speaks v1 only). The test funds an address the wallet derived, mines one // block, and finds the coin. package rigscan import ( "context" "encoding/hex" "fmt" "os" "os/exec" "strings" "sync" "testing" "time" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/chaindata" "git.smesh.lol/nostrino/pkg/neutrino" "git.smesh.lol/tinywallet/pkg/domain" "git.smesh.lol/tinywallet/pkg/store" "git.smesh.lol/tinywallet/pkg/utxo" "git.smesh.lol/tinywallet/pkg/wallet" ) // The rig. const ( nodeAddress = "127.0.0.1:18444" rpcPort = "18443" bitcoinCLI = "/home/mleku/s/bitcoin-core/bin/bitcoin-cli" dataDir = "/home/mleku/.blake-swap-test/btc" phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" passphrase = "rigscan" ) // local adapts a light client to the wallet's Chain interface. type local struct { client *neutrino.Client } // Position reports the stored tips. func (l local) Position() (chaindata.Position, error) { tip, _, ok := l.client.Store().HeaderTip() if !ok { return chaindata.Position{}, nil } filters, _, ok := l.client.Store().FilterHeaderTip() if !ok { filters = tip } return chaindata.Position{Tip: tip, Filters: filters}, nil } // Sync advances the client from a height. func (l local) Sync(from uint32) (chaindata.Position, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() if _, err := l.client.SyncFrom(ctx, from, 0); err != nil { return chaindata.Position{}, err } return l.Position() } // Match asks the stored filters. func (l local) Match(scripts [][]byte) ([]uint32, error) { tip, _, ok := l.client.Store().HeaderTip() if !ok { return nil, nil } return l.client.MatchScripts(0, tip, scripts) } // Block fetches a block over the peer. func (l local) Block(height uint32) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() return l.client.Block(ctx, height) } // memPersistence is the store's durable side, in memory: this test is about the chain path, not // about IndexedDB. type memPersistence struct { mu sync.Mutex items map[string][]byte } func (m *memPersistence) Load() (map[string][]byte, error) { m.mu.Lock() defer m.mu.Unlock() out := make(map[string][]byte, len(m.items)) for key, value := range m.items { out[key] = append([]byte{}, value...) } return out, nil } func (m *memPersistence) Write(batch []store.Op) error { m.mu.Lock() defer m.mu.Unlock() for _, op := range batch { if op.Delete { delete(m.items, op.Key) continue } m.items[op.Key] = append([]byte{}, op.Value...) } return nil } // cli runs the regtest client. func cli(t *testing.T, args ...string) string { t.Helper() full := append([]string{"-datadir=" + dataDir, "-rpcport=" + rpcPort}, args...) out, err := exec.Command(bitcoinCLI, full...).CombinedOutput() if err != nil { t.Fatalf("%s: %v: %s", strings.Join(args, " "), err, out) } return strings.TrimSpace(string(out)) } // displayTxID renders a transaction id the way the node prints it: the internal hash reversed. func displayTxID(txid [32]byte) string { reversed := make([]byte, len(txid)) for i, b := range txid { reversed[len(txid)-1-i] = b } return hex.EncodeToString(reversed) } // TestAFilterMatchBecomesACoin: derive an address, fund it, mine, sync, and find the coin. func TestAFilterMatchBecomesACoin(t *testing.T) { if os.Getenv("NOSTRINO_RIG") != "1" { t.Skip("NOSTRINO_RIG is not set, and this test needs the regtest rig") } profile, err := chain.ByName("regtest") if err != nil { t.Fatal(err) } client, err := neutrino.New(neutrino.Config{ Profile: profile, Store: neutrino.NewMemory(), Address: nodeAddress, }) if err != nil { t.Fatal(err) } defer client.Close() cache, err := store.OpenManual(&memPersistence{items: map[string][]byte{}}) if err != nil { t.Fatal(err) } defer func() { _ = cache.Close() }() dom, err := domain.New(cache) if err != nil { t.Fatal(err) } walletProfile, err := wallet.ProfileFor("btc", "regtest") if err != nil { t.Fatal(err) } chain := local{client: client} if err := dom.Add("btc", domain.Link{Profile: walletProfile, Client: chain}); err != nil { t.Fatal(err) } created, err := dom.Restore("btc", phrase, passphrase) if err != nil { t.Fatal(err) } // Catch up with the chain before mining, so the new block is the tip. report, err := dom.Sync("btc", 0) if err != nil { t.Fatal(err) } t.Logf("synced to tip %d from %d", report.Position.Tip, report.From) // Fund the derived address from the rig wallet. A regtest coinbase is worthless at this // height: regtest halves the subsidy every 150 blocks, so at 24000 blocks it is exactly zero // and a mined coin would prove nothing about the value the block decoder reads. A real // funding transaction gives the coin a known amount and a txid that is not the coinbase. const funding = 1.0 // BTC fundingTxID := cli(t, "-rpcwallet=rig", "sendtoaddress", created.Address.Text, fmt.Sprintf("%.8f", funding)) // Mine to the node's own address, so the block pays the client's script exactly once. miningAddress := cli(t, "-rpcwallet=rig", "getnewaddress") cli(t, "-rpcwallet=rig", "generatetoaddress", "1", miningAddress) height := report.Position.Tip + 1 if got := cli(t, "getblockcount"); got != fmt.Sprintf("%d", height) { t.Fatalf("the node is at %s, the test expected %d", got, height) } if _, err := dom.Sync("btc", 0); err != nil { t.Fatal(err) } coins, err := dom.Unspent() if err != nil { t.Fatal(err) } // One coin, not "the only coin": the same deterministic address may have been paid before // (a zero-subsidy coinbase from an earlier run is a real coin), so the funding coin is // identified by the transaction that created it. var coin utxo.Coin found := 0 for _, c := range coins { if displayTxID(c.TxID) == fundingTxID { coin = c found++ } } if found != 1 { t.Fatalf("%d coins from %s among %d held", found, fundingTxID, len(coins)) } if coin.Height != height { t.Fatalf("the coin is at height %d, the block was %d", coin.Height, height) } if want := uint64(funding * 1e8); coin.Value != want { t.Fatalf("the coin is worth %d, the funding paid %d", coin.Value, want) } if got := displayTxID(coin.TxID); got != fundingTxID { t.Fatalf("the coin was created by %s, the funding transaction is %s", got, fundingTxID) } t.Logf("coin: %s:%d worth %d at height %d", displayTxID(coin.TxID), coin.Vout, coin.Value, coin.Height) // The node agrees the output exists, unspent, at that value: the wallet's answer is not a // local fiction. utxo := cli(t, "gettxout", fundingTxID, fmt.Sprintf("%d", coin.Vout)) if !strings.Contains(utxo, `"value": 1.00000000`) { t.Fatalf("gettxout for the coin: %s", utxo) } if !strings.Contains(utxo, `"confirmations": 1`) { t.Fatalf("gettxout does not show one confirmation: %s", utxo) } t.Logf("gettxout: %s", strings.Join(strings.Fields(utxo), " ")) // The node must not know the address: the wallet is the only holder of the key. info := cli(t, "getaddressinfo", created.Address.Text) if !strings.Contains(info, `"ismine": false`) { t.Fatalf("the node claims the client's address: %s", info) } }