rigscan_test.go raw

   1  // Package rigscan proves the whole path against a real node: a filter match becomes a verified
   2  // coin, with no wallet RPC anywhere in it.
   3  //
   4  // It needs the regtest rig, so it skips unless NOSTRINO_RIG=1. The node must serve BIP157
   5  // (-blockfilterindex=1 and -peerblockfilters=1), must have the "rig" wallet loaded to fund the
   6  // derived address, and must be reachable over v1 transport (-v2transport=0 is the safe setting,
   7  // because this client speaks v1 only). The test funds an address the wallet derived, mines one
   8  // block, and finds the coin.
   9  package rigscan
  10  
  11  import (
  12  	"context"
  13  	"encoding/hex"
  14  	"fmt"
  15  	"os"
  16  	"os/exec"
  17  	"strings"
  18  	"sync"
  19  	"testing"
  20  	"time"
  21  
  22  	"git.smesh.lol/nostrino/pkg/chain"
  23  	"git.smesh.lol/nostrino/pkg/chaindata"
  24  	"git.smesh.lol/nostrino/pkg/neutrino"
  25  	"git.smesh.lol/tinywallet/pkg/domain"
  26  	"git.smesh.lol/tinywallet/pkg/store"
  27  	"git.smesh.lol/tinywallet/pkg/utxo"
  28  	"git.smesh.lol/tinywallet/pkg/wallet"
  29  )
  30  
  31  // The rig.
  32  const (
  33  	nodeAddress = "127.0.0.1:18444"
  34  	rpcPort     = "18443"
  35  	bitcoinCLI  = "/home/mleku/s/bitcoin-core/bin/bitcoin-cli"
  36  	dataDir     = "/home/mleku/.blake-swap-test/btc"
  37  	phrase      = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
  38  	passphrase  = "rigscan"
  39  )
  40  
  41  // local adapts a light client to the wallet's Chain interface.
  42  type local struct {
  43  	client *neutrino.Client
  44  }
  45  
  46  // Position reports the stored tips.
  47  func (l local) Position() (chaindata.Position, error) {
  48  	tip, _, ok := l.client.Store().HeaderTip()
  49  
  50  	if !ok {
  51  		return chaindata.Position{}, nil
  52  	}
  53  
  54  	filters, _, ok := l.client.Store().FilterHeaderTip()
  55  
  56  	if !ok {
  57  		filters = tip
  58  	}
  59  
  60  	return chaindata.Position{Tip: tip, Filters: filters}, nil
  61  }
  62  
  63  // Sync advances the client from a height.
  64  func (l local) Sync(from uint32) (chaindata.Position, error) {
  65  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
  66  	defer cancel()
  67  
  68  	if _, err := l.client.SyncFrom(ctx, from, 0); err != nil {
  69  		return chaindata.Position{}, err
  70  	}
  71  
  72  	return l.Position()
  73  }
  74  
  75  // Match asks the stored filters.
  76  func (l local) Match(scripts [][]byte) ([]uint32, error) {
  77  	tip, _, ok := l.client.Store().HeaderTip()
  78  
  79  	if !ok {
  80  		return nil, nil
  81  	}
  82  
  83  	return l.client.MatchScripts(0, tip, scripts)
  84  }
  85  
  86  // Block fetches a block over the peer.
  87  func (l local) Block(height uint32) ([]byte, error) {
  88  	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
  89  	defer cancel()
  90  
  91  	return l.client.Block(ctx, height)
  92  }
  93  
  94  // memPersistence is the store's durable side, in memory: this test is about the chain path, not
  95  // about IndexedDB.
  96  type memPersistence struct {
  97  	mu    sync.Mutex
  98  	items map[string][]byte
  99  }
 100  
 101  func (m *memPersistence) Load() (map[string][]byte, error) {
 102  	m.mu.Lock()
 103  	defer m.mu.Unlock()
 104  
 105  	out := make(map[string][]byte, len(m.items))
 106  
 107  	for key, value := range m.items {
 108  		out[key] = append([]byte{}, value...)
 109  	}
 110  
 111  	return out, nil
 112  }
 113  
 114  func (m *memPersistence) Write(batch []store.Op) error {
 115  	m.mu.Lock()
 116  	defer m.mu.Unlock()
 117  
 118  	for _, op := range batch {
 119  		if op.Delete {
 120  			delete(m.items, op.Key)
 121  
 122  			continue
 123  		}
 124  
 125  		m.items[op.Key] = append([]byte{}, op.Value...)
 126  	}
 127  
 128  	return nil
 129  }
 130  
 131  // cli runs the regtest client.
 132  func cli(t *testing.T, args ...string) string {
 133  	t.Helper()
 134  
 135  	full := append([]string{"-datadir=" + dataDir, "-rpcport=" + rpcPort}, args...)
 136  	out, err := exec.Command(bitcoinCLI, full...).CombinedOutput()
 137  
 138  	if err != nil {
 139  		t.Fatalf("%s: %v: %s", strings.Join(args, " "), err, out)
 140  	}
 141  
 142  	return strings.TrimSpace(string(out))
 143  }
 144  
 145  // displayTxID renders a transaction id the way the node prints it: the internal hash reversed.
 146  func displayTxID(txid [32]byte) string {
 147  	reversed := make([]byte, len(txid))
 148  
 149  	for i, b := range txid {
 150  		reversed[len(txid)-1-i] = b
 151  	}
 152  
 153  	return hex.EncodeToString(reversed)
 154  }
 155  
 156  // TestAFilterMatchBecomesACoin: derive an address, fund it, mine, sync, and find the coin.
 157  func TestAFilterMatchBecomesACoin(t *testing.T) {
 158  	if os.Getenv("NOSTRINO_RIG") != "1" {
 159  		t.Skip("NOSTRINO_RIG is not set, and this test needs the regtest rig")
 160  	}
 161  
 162  	profile, err := chain.ByName("regtest")
 163  
 164  	if err != nil {
 165  		t.Fatal(err)
 166  	}
 167  
 168  	client, err := neutrino.New(neutrino.Config{
 169  		Profile: profile,
 170  		Store:   neutrino.NewMemory(),
 171  		Address: nodeAddress,
 172  	})
 173  
 174  	if err != nil {
 175  		t.Fatal(err)
 176  	}
 177  
 178  	defer client.Close()
 179  
 180  	cache, err := store.OpenManual(&memPersistence{items: map[string][]byte{}})
 181  
 182  	if err != nil {
 183  		t.Fatal(err)
 184  	}
 185  
 186  	defer func() { _ = cache.Close() }()
 187  
 188  	dom, err := domain.New(cache)
 189  
 190  	if err != nil {
 191  		t.Fatal(err)
 192  	}
 193  
 194  	walletProfile, err := wallet.ProfileFor("btc", "regtest")
 195  
 196  	if err != nil {
 197  		t.Fatal(err)
 198  	}
 199  
 200  	chain := local{client: client}
 201  
 202  	if err := dom.Add("btc", domain.Link{Profile: walletProfile, Client: chain}); err != nil {
 203  		t.Fatal(err)
 204  	}
 205  
 206  	created, err := dom.Restore("btc", phrase, passphrase)
 207  
 208  	if err != nil {
 209  		t.Fatal(err)
 210  	}
 211  
 212  	// Catch up with the chain before mining, so the new block is the tip.
 213  	report, err := dom.Sync("btc", 0)
 214  
 215  	if err != nil {
 216  		t.Fatal(err)
 217  	}
 218  
 219  	t.Logf("synced to tip %d from %d", report.Position.Tip, report.From)
 220  
 221  	// Fund the derived address from the rig wallet. A regtest coinbase is worthless at this
 222  	// height: regtest halves the subsidy every 150 blocks, so at 24000 blocks it is exactly zero
 223  	// and a mined coin would prove nothing about the value the block decoder reads. A real
 224  	// funding transaction gives the coin a known amount and a txid that is not the coinbase.
 225  	const funding = 1.0 // BTC
 226  
 227  	fundingTxID := cli(t, "-rpcwallet=rig", "sendtoaddress", created.Address.Text, fmt.Sprintf("%.8f", funding))
 228  
 229  	// Mine to the node's own address, so the block pays the client's script exactly once.
 230  	miningAddress := cli(t, "-rpcwallet=rig", "getnewaddress")
 231  
 232  	cli(t, "-rpcwallet=rig", "generatetoaddress", "1", miningAddress)
 233  
 234  	height := report.Position.Tip + 1
 235  
 236  	if got := cli(t, "getblockcount"); got != fmt.Sprintf("%d", height) {
 237  		t.Fatalf("the node is at %s, the test expected %d", got, height)
 238  	}
 239  
 240  	if _, err := dom.Sync("btc", 0); err != nil {
 241  		t.Fatal(err)
 242  	}
 243  
 244  	coins, err := dom.Unspent()
 245  
 246  	if err != nil {
 247  		t.Fatal(err)
 248  	}
 249  
 250  	// One coin, not "the only coin": the same deterministic address may have been paid before
 251  	// (a zero-subsidy coinbase from an earlier run is a real coin), so the funding coin is
 252  	// identified by the transaction that created it.
 253  	var coin utxo.Coin
 254  
 255  	found := 0
 256  
 257  	for _, c := range coins {
 258  		if displayTxID(c.TxID) == fundingTxID {
 259  			coin = c
 260  			found++
 261  		}
 262  	}
 263  
 264  	if found != 1 {
 265  		t.Fatalf("%d coins from %s among %d held", found, fundingTxID, len(coins))
 266  	}
 267  
 268  	if coin.Height != height {
 269  		t.Fatalf("the coin is at height %d, the block was %d", coin.Height, height)
 270  	}
 271  
 272  	if want := uint64(funding * 1e8); coin.Value != want {
 273  		t.Fatalf("the coin is worth %d, the funding paid %d", coin.Value, want)
 274  	}
 275  
 276  	if got := displayTxID(coin.TxID); got != fundingTxID {
 277  		t.Fatalf("the coin was created by %s, the funding transaction is %s", got, fundingTxID)
 278  	}
 279  
 280  	t.Logf("coin: %s:%d worth %d at height %d", displayTxID(coin.TxID), coin.Vout, coin.Value, coin.Height)
 281  
 282  	// The node agrees the output exists, unspent, at that value: the wallet's answer is not a
 283  	// local fiction.
 284  	utxo := cli(t, "gettxout", fundingTxID, fmt.Sprintf("%d", coin.Vout))
 285  
 286  	if !strings.Contains(utxo, `"value": 1.00000000`) {
 287  		t.Fatalf("gettxout for the coin: %s", utxo)
 288  	}
 289  
 290  	if !strings.Contains(utxo, `"confirmations": 1`) {
 291  		t.Fatalf("gettxout does not show one confirmation: %s", utxo)
 292  	}
 293  
 294  	t.Logf("gettxout: %s", strings.Join(strings.Fields(utxo), " "))
 295  
 296  	// The node must not know the address: the wallet is the only holder of the key.
 297  	info := cli(t, "getaddressinfo", created.Address.Text)
 298  
 299  	if !strings.Contains(info, `"ismine": false`) {
 300  		t.Fatalf("the node claims the client's address: %s", info)
 301  	}
 302  }
 303