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