scan_test.go raw
1 package domain
2
3 import (
4 "crypto/sha256"
5 "encoding/binary"
6 "testing"
7
8 "git.smesh.lol/nostrino/pkg/chaindata"
9 )
10
11 // buildBlock assembles a one-transaction block that pays script, with a header whose merkle
12 // root field is the transaction's own id.
13 //
14 // It is not a real block — there is no proof of work — but it is a well-formed one, which is
15 // what pkg/block checks: the merkle root is the commitment the domain verifies, and it is
16 // computed here rather than taken on trust.
17 func buildBlock(t *testing.T, script []byte, value uint64) []byte {
18 t.Helper()
19
20 // One transaction: version, one input spending a made-up outpoint, one output, locktime.
21 tx := make([]byte, 0, 128)
22 tx = binary.LittleEndian.AppendUint32(tx, 2)
23 tx = append(tx, 0x01)
24 tx = append(tx, make([]byte, 32)...)
25 tx = binary.LittleEndian.AppendUint32(tx, 0)
26 tx = append(tx, 0x00)
27 tx = binary.LittleEndian.AppendUint32(tx, 0xffffffff)
28 tx = append(tx, 0x01)
29 tx = binary.LittleEndian.AppendUint64(tx, value)
30 tx = append(tx, compactSize(len(script))...)
31 tx = append(tx, script...)
32 tx = binary.LittleEndian.AppendUint32(tx, 0)
33
34 first := sha256.Sum256(tx)
35 txid := sha256.Sum256(first[:])
36
37 header := make([]byte, 80)
38 binary.LittleEndian.PutUint32(header[0:4], 1)
39 copy(header[36:68], txid[:])
40
41 out := append([]byte{}, header...)
42 out = append(out, 0x01)
43 out = append(out, tx...)
44
45 return out
46 }
47
48 // compactSize writes a length prefix.
49 func compactSize(value int) []byte {
50 if value < 0xfd {
51 return []byte{byte(value)}
52 }
53
54 return []byte{0xfd, byte(value), byte(value >> 8)}
55 }
56
57 // TestSyncTurnsAMatchIntoACoin: the whole path — a matched height, the block it names, the
58 // output that pays the wallet, and a coin that survives as a coin.
59 func TestSyncTurnsAMatchIntoACoin(t *testing.T) {
60 r := newRig(t)
61
62 created, err := r.domain.Restore("btc", phrase, "pass")
63
64 if err != nil {
65 t.Fatal(err)
66 }
67
68 address, err := r.domain.Addresses("btc", false, 0, 1)
69
70 if err != nil {
71 t.Fatal(err)
72 }
73
74 script := address[0].Script()
75
76 chain := r.chains["btc"]
77 chain.syncTo = chaindataPosition(200)
78 chain.matched = []uint32{150}
79 chain.blocks = map[uint32][]byte{150: buildBlock(t, script, 70000)}
80
81 report, err := r.domain.Sync("btc", 0)
82
83 if err != nil {
84 t.Fatal(err)
85 }
86
87 if len(report.New) != 1 || report.New[0] != 150 {
88 t.Fatalf("new matches are %v", report.New)
89 }
90
91 coins, err := r.domain.Unspent()
92
93 if err != nil {
94 t.Fatal(err)
95 }
96
97 if len(coins) != 1 {
98 t.Fatalf("%d coins", len(coins))
99 }
100
101 // The coin pays the wallet's own script: the address we just derived, and no other.
102 if coins[0].Value != 70000 || coins[0].Vout != 0 || coins[0].Height != 150 {
103 t.Fatalf("the coin is %+v", coins[0])
104 }
105
106 if showAddress(t, created.Address.Path.String()) == "" {
107 t.Fatal("the created address has no path")
108 }
109
110 if chain.blockCalls != 1 {
111 t.Fatalf("%d block fetches", chain.blockCalls)
112 }
113
114 // A second sync does not fetch the block again.
115 report, err = r.domain.Sync("btc", 0)
116
117 if err != nil {
118 t.Fatal(err)
119 }
120
121 if len(report.New) != 0 {
122 t.Fatalf("the second sync found %v", report.New)
123 }
124
125 if chain.blockCalls != 1 {
126 t.Fatalf("the block was fetched %d times", chain.blockCalls)
127 }
128 }
129
130 // TestACorruptBlockLeavesTheHeightUnscanned: a block that fails its merkle check must not mark
131 // the height as done, or the coins it should have produced are lost silently.
132 func TestACorruptBlockLeavesTheHeightUnscanned(t *testing.T) {
133 r := newRig(t)
134
135 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
136 t.Fatal(err)
137 }
138
139 addresses, err := r.domain.Addresses("btc", false, 0, 1)
140
141 if err != nil {
142 t.Fatal(err)
143 }
144
145 raw := buildBlock(t, addresses[0].Script(), 50000)
146 raw[81] ^= 0xff
147
148 chain := r.chains["btc"]
149 chain.syncTo = chaindataPosition(10)
150 chain.matched = []uint32{9}
151 chain.blocks = map[uint32][]byte{9: raw}
152
153 if _, err := r.domain.Sync("btc", 0); err == nil {
154 t.Fatal("a corrupt block was accepted")
155 }
156
157 if len(r.domain.Matches("btc")) != 0 {
158 t.Fatal("the height was marked as scanned")
159 }
160
161 // The next sync retries the height, and a good block produces the coin.
162 chain.blocks[9] = buildBlock(t, addresses[0].Script(), 50000)
163
164 if _, err := r.domain.Sync("btc", 0); err != nil {
165 t.Fatal(err)
166 }
167
168 coins, err := r.domain.Unspent()
169
170 if err != nil {
171 t.Fatal(err)
172 }
173
174 if len(coins) != 1 || coins[0].Value != 50000 {
175 t.Fatalf("the retry produced %+v", coins)
176 }
177 }
178
179 // TestAValidBlockThatPaysNobodyIsStillScanned: the marker records that the block was checked,
180 // not that it paid, so a later sync does not download it again.
181 func TestAValidBlockThatPaysNobodyIsStillScanned(t *testing.T) {
182 r := newRig(t)
183
184 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
185 t.Fatal(err)
186 }
187
188 chain := r.chains["btc"]
189 chain.syncTo = chaindataPosition(5)
190 chain.matched = []uint32{4}
191 chain.blocks = map[uint32][]byte{4: buildBlock(t, []byte{0x00, 0x14, 0x99}, 1000)}
192
193 if _, err := r.domain.Sync("btc", 0); err != nil {
194 t.Fatal(err)
195 }
196
197 if got := r.domain.Matches("btc"); len(got) != 1 || got[0] != 4 {
198 t.Fatalf("the recorded heights are %v", got)
199 }
200
201 coins, err := r.domain.Unspent()
202
203 if err != nil {
204 t.Fatal(err)
205 }
206
207 if len(coins) != 0 {
208 t.Fatalf("%d coins for a block that pays somebody else", len(coins))
209 }
210
211 chain.blockCalls = 0
212
213 if _, err := r.domain.Sync("btc", 0); err != nil {
214 t.Fatal(err)
215 }
216
217 if chain.blockCalls != 0 {
218 t.Fatalf("a scanned height was fetched again %d times", chain.blockCalls)
219 }
220 }
221
222 // showAddress is a tiny helper so the created address's path is asserted somewhere.
223 func showAddress(t *testing.T, path string) string {
224 t.Helper()
225
226 return path
227 }
228
229 // chaindataPosition is the position a fake chain reports after a sync.
230 func chaindataPosition(tip uint32) chaindata.Position {
231 return chaindata.Position{Tip: tip, Filters: tip}
232 }
233