wallet_test.go raw
1 package wallet
2
3 import (
4 "encoding/hex"
5 "strings"
6 "testing"
7
8 "git.smesh.lol/tinywallet/pkg/bech32"
9 "git.smesh.lol/tinywallet/pkg/mnemonic"
10 )
11
12 // bip39Phrase is the reference phrase the BIP84 vectors use: twelve words of zero entropy.
13 const bip39Phrase = "abandon abandon abandon abandon abandon abandon " +
14 "abandon abandon abandon abandon abandon about"
15
16 // seedOf derives the reference seed for a phrase and passphrase.
17 func seedOf(t *testing.T, phrase, passphrase string) [64]byte {
18 t.Helper()
19
20 seed, err := mnemonic.Seed(phrase, passphrase)
21
22 if err != nil {
23 t.Fatal(err)
24 }
25
26 return seed
27 }
28
29 // TestBIP84Vectors derives the three addresses the BIP84 specification publishes.
30 //
31 // It is the whole stack in one assertion: the BIP39 seed, BIP32 private derivation, the
32 // secp256k1 public key, hash160 and the bech32 encoder. If any of them is wrong the address
33 // is not this one, and the specification is the authority rather than this package's own
34 // output.
35 func TestBIP84Vectors(t *testing.T) {
36 profile, err := ProfileFor("btc", "btc")
37
38 if err != nil {
39 t.Fatal(err)
40 }
41
42 w, err := New(seedOf(t, bip39Phrase, ""), profile)
43
44 if err != nil {
45 t.Fatal(err)
46 }
47
48 for _, test := range []struct {
49 change bool
50 index uint32
51 pubkey string
52 address string
53 }{
54 {
55 change: false, index: 0,
56 pubkey: "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
57 address: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
58 },
59 {
60 change: false, index: 1,
61 pubkey: "03e775fd51f0dfb8cd865d9ff1cca2a158cf651fe997fdc9fee9c1d3b5e995ea77",
62 address: "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g",
63 },
64 {
65 change: true, index: 0,
66 pubkey: "03025324888e429ab8e3dbaf1f7802648b9cd01e9b418485c5fa4c1b9b5700e1a6",
67 address: "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el",
68 },
69 } {
70 address, err := w.Address(test.change, test.index)
71
72 if err != nil {
73 t.Fatalf("%v/%d: %v", test.change, test.index, err)
74 }
75
76 if hex.EncodeToString(address.Pubkey) != test.pubkey {
77 t.Fatalf("%v/%d pubkey is %s, want %s", test.change, test.index,
78 hex.EncodeToString(address.Pubkey), test.pubkey)
79 }
80
81 if address.Text != test.address {
82 t.Fatalf("%v/%d address is %s, want %s", test.change, test.index,
83 address.Text, test.address)
84 }
85
86 if address.Path.String() != pathString(test.change, test.index) {
87 t.Fatalf("%v/%d path is %s", test.change, test.index, address.Path)
88 }
89 }
90 }
91
92 // pathString is the path the BIP84 vectors name.
93 func pathString(change bool, index uint32) string {
94 branch := "0"
95
96 if change {
97 branch = "1"
98 }
99
100 return "m/84'/0'/0'/" + branch + "/" + itoa(index)
101 }
102
103 // itoa renders a small index.
104 func itoa(value uint32) string {
105 if value == 0 {
106 return "0"
107 }
108
109 var digits []byte
110
111 for value > 0 {
112 digits = append([]byte{byte('0' + value%10)}, digits...)
113 value /= 10
114 }
115
116 return string(digits)
117 }
118
119 // TestGeneratorAddress: the P2WPKH address of the secp256k1 generator point is the
120 // canonical BIP173 example, so the address encoder is checked against something other than
121 // the derivation that feeds it.
122 func TestGeneratorAddress(t *testing.T) {
123 pubkey, err := hex.DecodeString(
124 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
125
126 if err != nil {
127 t.Fatal(err)
128 }
129
130 program := hash160(pubkey)
131
132 address, err := bech32.EncodeSegwit("bc", 0, program[:])
133
134 if err != nil {
135 t.Fatal(err)
136 }
137
138 if address != "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" {
139 t.Fatalf("the generator address is %s", address)
140 }
141 }
142
143 // TestTheTwoChainsDeriveDifferentKeys: one seed, two accounts, no shared key.
144 //
145 // This is the property the forked chain depends on: it has no registered coin type, so it
146 // takes a second account under the same one, and a mistake here would put the same key on
147 // two chains.
148 func TestTheTwoChainsDeriveDifferentKeys(t *testing.T) {
149 seed := seedOf(t, bip39Phrase, "")
150
151 btcProfile, err := ProfileFor("btc", "regtest")
152
153 if err != nil {
154 t.Fatal(err)
155 }
156
157 blakeProfile, err := ProfileFor("blake", "regtest")
158
159 if err != nil {
160 t.Fatal(err)
161 }
162
163 btc, err := New(seed, btcProfile)
164
165 if err != nil {
166 t.Fatal(err)
167 }
168
169 blake, err := New(seed, blakeProfile)
170
171 if err != nil {
172 t.Fatal(err)
173 }
174
175 btcAddress, err := btc.Address(false, 0)
176
177 if err != nil {
178 t.Fatal(err)
179 }
180
181 blakeAddress, err := blake.Address(false, 0)
182
183 if err != nil {
184 t.Fatal(err)
185 }
186
187 if hex.EncodeToString(btcAddress.Pubkey) == hex.EncodeToString(blakeAddress.Pubkey) {
188 t.Fatal("the two chains derived the same public key")
189 }
190
191 if btcAddress.Program == blakeAddress.Program {
192 t.Fatal("the two chains derived the same witness program")
193 }
194
195 if btcAddress.Text == blakeAddress.Text {
196 t.Fatal("the two chains derived the same address")
197 }
198
199 // Both chains are on the same network, so they share the coin type and differ only in
200 // the account: that is what one seed, two wallets means.
201 if btcAddress.Path.String() != "m/84'/1'/0'/0/0" {
202 t.Fatalf("the btc path is %s", btcAddress.Path)
203 }
204
205 if blakeAddress.Path.String() != "m/84'/1'/1'/0/0" {
206 t.Fatalf("the blake path is %s", blakeAddress.Path)
207 }
208
209 // The two profiles are still the same network family: only the account differs.
210 if btcProfile.CoinType != blakeProfile.CoinType {
211 t.Fatalf("the coin types differ: %d and %d", btcProfile.CoinType,
212 blakeProfile.CoinType)
213 }
214
215 // Both regtest profiles carry the regtest prefix, which is the point: the fork is the
216 // same network as Bitcoin, so the account is what separates the two wallets.
217 if btcProfile.HRP != "bcrt" || blakeProfile.HRP != "bcrt" {
218 t.Fatalf("the regtest prefixes are %q and %q", btcProfile.HRP, blakeProfile.HRP)
219 }
220 }
221
222 // TestAddressIsChainScoped: an address carries the prefix of its chain, and decoding one
223 // under another chain's prefix is a different string.
224 func TestAddressIsChainScoped(t *testing.T) {
225 profile, err := ProfileFor("btc", "btc")
226
227 if err != nil {
228 t.Fatal(err)
229 }
230
231 // The rig is regtest, so the prefix the wallet uses there is bcrt.
232 profile.HRP = "bcrt"
233
234 w, err := New(seedOf(t, bip39Phrase, ""), profile)
235
236 if err != nil {
237 t.Fatal(err)
238 }
239
240 address, err := w.Address(false, 0)
241
242 if err != nil {
243 t.Fatal(err)
244 }
245
246 if !strings.HasPrefix(address.Text, "bcrt1") {
247 t.Fatalf("the regtest address is %s", address.Text)
248 }
249
250 hrp, version, program, err := bech32.DecodeSegwit(address.Text)
251
252 if err != nil {
253 t.Fatal(err)
254 }
255
256 if hrp != "bcrt" || version != 0 || program == nil {
257 t.Fatalf("decoded to %q/%d/%x", hrp, version, program)
258 }
259
260 if address.Chain != "btc" {
261 t.Fatalf("the address names chain %q", address.Chain)
262 }
263
264 // The same program under the wrong prefix is not the same address, which is what makes
265 // showing a BTC address as an XBT one a real mistake rather than a cosmetic one.
266 mainnet, err := bech32.EncodeSegwit("bc", 0, program)
267
268 if err != nil {
269 t.Fatal(err)
270 }
271
272 if mainnet == address.Text {
273 t.Fatal("an address is identical under two prefixes")
274 }
275 }
276
277 // TestScript: the filter client matches a script, not an address.
278 func TestScript(t *testing.T) {
279 profile, err := ProfileFor("btc", "btc")
280
281 if err != nil {
282 t.Fatal(err)
283 }
284
285 w, err := New(seedOf(t, bip39Phrase, ""), profile)
286
287 if err != nil {
288 t.Fatal(err)
289 }
290
291 address, err := w.Address(false, 0)
292
293 if err != nil {
294 t.Fatal(err)
295 }
296
297 script := address.Script()
298
299 if len(script) != 22 {
300 t.Fatalf("the script is %d bytes", len(script))
301 }
302
303 if script[0] != 0x00 || script[1] != 0x14 {
304 t.Fatalf("the script is OP_%d, push %d", script[0], script[1])
305 }
306
307 if hex.EncodeToString(script[2:]) != hex.EncodeToString(address.Program[:]) {
308 t.Fatal("the script's program is not the address's")
309 }
310 }
311
312 // TestAddressIndexRange: an index at or above the hardened offset is refused, because it
313 // would be a different derivation level rather than an address.
314 func TestAddressIndexRange(t *testing.T) {
315 profile, err := ProfileFor("btc", "btc")
316
317 if err != nil {
318 t.Fatal(err)
319 }
320
321 w, err := New(seedOf(t, bip39Phrase, ""), profile)
322
323 if err != nil {
324 t.Fatal(err)
325 }
326
327 if _, err := w.Address(false, 0x80000000); err == nil {
328 t.Fatal("a hardened index was accepted as an address index")
329 }
330
331 if _, err := w.Addresses(false, 0, 0); err == nil {
332 t.Fatal("an empty run of addresses was accepted")
333 }
334
335 run, err := w.Addresses(false, 5, 3)
336
337 if err != nil {
338 t.Fatal(err)
339 }
340
341 if len(run) != 3 {
342 t.Fatalf("the run holds %d addresses", len(run))
343 }
344
345 for i, address := range run {
346 if address.Index != uint32(5+i) {
347 t.Fatalf("address %d has index %d", i, address.Index)
348 }
349 }
350 }
351
352 // TestProfileNames: the repository speaks "blake" for the forked chain and "xbt" only as a
353 // display name, so both resolve to one profile.
354 func TestProfileNames(t *testing.T) {
355 for _, name := range []string{"blake", "XBT"} {
356 profile, err := ProfileFor(name, "regtest")
357
358 if err != nil {
359 t.Fatalf("%s: %v", name, err)
360 }
361
362 if profile.Name != "blake" || profile.Account != AccountBlake {
363 t.Fatalf("%s gave %+v", name, profile)
364 }
365 }
366
367 if _, err := ProfileFor("litecoin", "regtest"); err == nil {
368 t.Fatal("an unknown chain resolved to a profile")
369 }
370 }
371
372 // TestSeedIsNotInTheAddress: nothing a user shows another person carries the seed.
373 func TestSeedIsNotInTheAddress(t *testing.T) {
374 seed := seedOf(t, bip39Phrase, "")
375
376 profile, err := ProfileFor("btc", "btc")
377
378 if err != nil {
379 t.Fatal(err)
380 }
381
382 w, err := New(seed, profile)
383
384 if err != nil {
385 t.Fatal(err)
386 }
387
388 address, err := w.Address(false, 0)
389
390 if err != nil {
391 t.Fatal(err)
392 }
393
394 encoded := hex.EncodeToString(seed[:])
395
396 if strings.Contains(address.Text, encoded[:16]) {
397 t.Fatal("the address contains the seed")
398 }
399
400 if strings.Contains(hex.EncodeToString(address.Pubkey), encoded[:16]) {
401 t.Fatal("the public key contains the seed")
402 }
403 }
404