main.go raw
1 //go:build js && wasm
2
3 // Command tinywallet-wasm is the wallet worker: it owns the seed, the store and one
4 // chain-data client per chain, and answers the page in frames.
5 //
6 // The page creates one MessageChannel to this worker and one to each nostrino worker, then
7 // calls
8 //
9 // tinywallet.open(pagePort, [{name, network, port}, ...])
10 //
11 // It returns "" on success or the reason it could not start. After that the page sends
12 // request frames and receives result frames; nothing else crosses. The wallet does not call
13 // functions on the page and the page does not reach into the wallet: the frames are the whole
14 // contract, which is why a page and a test can both drive it.
15 package main
16
17 import (
18 "errors"
19 "fmt"
20 "sync"
21 "syscall/js"
22
23 "git.smesh.lol/nostrino/pkg/chaindata"
24 "git.smesh.lol/nostrino/pkg/msgport"
25 "git.smesh.lol/tiny-spawn"
26 "git.smesh.lol/tinywallet/pkg/api"
27 "git.smesh.lol/tinywallet/pkg/domain"
28 "git.smesh.lol/tinywallet/pkg/store"
29 "git.smesh.lol/tinywallet/pkg/wallet"
30 )
31
32 func main() {
33 js.Global().Set("tinywallet", js.ValueOf(map[string]any{
34 "open": js.FuncOf(jsOpen),
35 }))
36
37 select {}
38 }
39
40 // spec is one chain the page wants the wallet to drive.
41 type spec struct {
42 // name is the wallet's chain name: "btc" or "blake".
43 name string
44 // network is the chain profile name: "btc", "regtest", "testnet3", "signet".
45 network string
46 // port is the wallet's end of the channel to that chain's nostrino worker.
47 port msgport.Port
48 }
49
50 // jsOpen starts the wallet.
51 func jsOpen(_ js.Value, args []js.Value) any {
52 if len(args) < 2 {
53 return "open(pagePort, chains) needs a page port and a chain list"
54 }
55
56 pagePort, err := msgport.NewMessagePort(args[0])
57
58 if err != nil {
59 return err.Error()
60 }
61
62 specs, err := parseSpecs(args[1])
63
64 if err != nil {
65 _ = pagePort.Close()
66
67 return err.Error()
68 }
69
70 cache, err := openStore()
71
72 if err != nil {
73 _ = pagePort.Close()
74
75 return err.Error()
76 }
77
78 dom, err := domain.New(cache)
79
80 if err != nil {
81 return err.Error()
82 }
83
84 stop := tinyspawn.NewBreaker()
85 clients := map[string]*chaindata.Client{}
86
87 for _, link := range specs {
88 profile, err := wallet.ProfileFor(link.name, link.network)
89
90 if err != nil {
91 return err.Error()
92 }
93
94 client := chaindata.NewClient(link.name, msgport.Duplex(link.port), stop)
95 clients[link.name] = client
96
97 if err := dom.Add(link.name, domain.Link{Profile: profile, Client: client}); err != nil {
98 return err.Error()
99 }
100 }
101
102 go serve(pagePort, dom, clients)
103
104 return ""
105 }
106
107 // openStore opens the browser store.
108 //
109 // A failure here is fatal to the worker, unlike on the chain side: a wallet whose store cannot
110 // open would answer "no wallet" for a funded seed, and that is the one lie this code must not
111 // tell.
112 func openStore() (*store.Cache, error) {
113 persist, err := store.OpenIndexedDB("tinywallet")
114
115 if err != nil {
116 return nil, fmt.Errorf("store: %w", err)
117 }
118
119 cache, err := store.Open(persist)
120
121 if err != nil {
122 return nil, fmt.Errorf("store: %w", err)
123 }
124
125 return cache, nil
126 }
127
128 // parseSpecs reads the chain list the page passes.
129 func parseSpecs(value js.Value) ([]spec, error) {
130 if value.Type() != js.TypeObject {
131 return nil, errors.New("wallet: the chain list is not an array")
132 }
133
134 length := value.Length()
135
136 if length == 0 {
137 return nil, errors.New("wallet: no chains were given")
138 }
139
140 out := make([]spec, 0, length)
141
142 for index := 0; index < length; index++ {
143 entry := value.Index(index)
144
145 name := entry.Get("name").String()
146 network := entry.Get("network").String()
147
148 if name == "" || network == "" {
149 return nil, fmt.Errorf("wallet: chain %d needs a name and a network", index)
150 }
151
152 port, err := msgport.NewMessagePort(entry.Get("port"))
153
154 if err != nil {
155 return nil, fmt.Errorf("wallet: chain %q: %w", name, err)
156 }
157
158 out = append(out, spec{name: name, network: network, port: port})
159 }
160
161 return out, nil
162 }
163
164 // serve reads the page's frames and answers them.
165 //
166 // Each request runs in its own goroutine so a sync does not hold up a status query, and the
167 // map of in-flight requests is what lets a cancel reach the chain that is working.
168 func serve(port msgport.Port, dom *domain.Domain, clients map[string]*chaindata.Client) {
169 var (
170 mu sync.Mutex
171 active = map[uint32]string{}
172 )
173
174 for raw := range port.Receive() {
175 request, err := api.Decode(raw)
176
177 if err != nil {
178 continue
179 }
180
181 switch request.Kind {
182 case api.KindRequest:
183 mu.Lock()
184 active[request.ID] = requestChain(dom, request)
185
186 chain := active[request.ID]
187 mu.Unlock()
188
189 go func(request api.Frame, chain string) {
190 answer := handle(dom, request)
191
192 mu.Lock()
193 delete(active, request.ID)
194 mu.Unlock()
195
196 if err := port.Send(answer); err != nil {
197 console("warn: wallet: sending an answer: " + err.Error())
198 }
199
200 _ = chain
201 }(request, chain)
202
203 case api.KindCancel:
204 mu.Lock()
205 chain, ok := active[request.ID]
206 delete(active, request.ID)
207 mu.Unlock()
208
209 if !ok {
210 continue
211 }
212
213 // The only request that runs long enough to cancel is a sync, and cancelling it
214 // is cancelling the chain's work: the client withdraws what it has in flight.
215 if client, ok := clients[chain]; ok {
216 client.CancelPending()
217 }
218
219 case api.KindClose:
220 return
221 }
222 }
223 }
224
225 // requestChain names the chain a request is about, without decoding its payload twice.
226 func requestChain(dom *domain.Domain, request api.Frame) string {
227 payload := request.Payload
228
229 if len(payload) < 2 {
230 return ""
231 }
232
233 // Every request payload starts with the chain name, so the name is readable the same way
234 // for all of them.
235 text, _, err := api.ReadText(payload)
236
237 if err != nil {
238 return ""
239 }
240
241 for _, name := range dom.Names() {
242 if name == text {
243 return text
244 }
245 }
246
247 return ""
248 }
249
250 // handle runs one request and renders its answer.
251 func handle(dom *domain.Domain, request api.Frame) []byte {
252 answer := api.Frame{Kind: api.KindResult, Method: request.Method, ID: request.ID}
253
254 payload, err := dispatch(dom, request)
255
256 switch {
257 case errors.Is(err, api.ErrBadFrame):
258 answer.Status = api.StatusMalformed
259 answer.Payload = api.PutText(nil, err.Error())
260 case errors.Is(err, errNoMethod):
261 answer.Status = api.StatusUnknownMethod
262 answer.Payload = api.PutText(nil, "wallet: no method "+request.Method.String())
263 case err != nil:
264 answer.Status = api.StatusFailed
265 answer.Payload = api.PutText(nil, err.Error())
266 default:
267 answer.Status = api.StatusOK
268 answer.Payload = payload
269 }
270
271 encoded, err := api.Encode(answer)
272
273 if err != nil {
274 fallback, _ := api.Encode(api.Message(api.StatusMalformed, request.ID, err.Error()))
275
276 return fallback
277 }
278
279 return encoded
280 }
281
282 // errNoMethod is a method this build does not have.
283 var errNoMethod = errors.New("wallet: no such method")
284
285 // dispatch routes a request to the domain.
286 func dispatch(dom *domain.Domain, request api.Frame) ([]byte, error) {
287 switch request.Method {
288 case api.MethodChains:
289 return api.Chains{Names: dom.Names()}.Encode(), nil
290
291 case api.MethodStatus:
292 call, err := api.ParseChainRequest(request.Payload)
293
294 if err != nil {
295 return nil, err
296 }
297
298 return statusOf(dom, call.Chain)
299
300 case api.MethodCreate, api.MethodRestore:
301 return setup(dom, request)
302
303 case api.MethodUnlock:
304 call, err := api.ParseUnlockRequest(request.Payload)
305
306 if err != nil {
307 return nil, err
308 }
309
310 if err := dom.Unlock(call.Chain, call.Passphrase); err != nil {
311 return nil, err
312 }
313
314 return api.PutText(nil, ""), nil
315
316 case api.MethodLock:
317 call, err := api.ParseChainRequest(request.Payload)
318
319 if err != nil {
320 return nil, err
321 }
322
323 dom.Lock(call.Chain)
324
325 return api.PutText(nil, ""), nil
326
327 case api.MethodAddresses:
328 return addresses(dom, request)
329
330 case api.MethodSync:
331 call, err := api.ParseSyncRequest(request.Payload)
332
333 if err != nil {
334 return nil, err
335 }
336
337 report, err := dom.Sync(call.Chain, call.Lookahead)
338
339 if err != nil {
340 return nil, err
341 }
342
343 return api.SyncResult{
344 From: report.From,
345 Tip: report.Position.Tip,
346 Filters: report.Position.Filters,
347 Scripts: uint32(report.Scripts),
348 Matched: report.Matched,
349 New: report.New,
350 }.Encode(), nil
351
352 case api.MethodUnspent:
353 coins, err := dom.Unspent()
354
355 if err != nil {
356 return nil, err
357 }
358
359 list := make([]api.Coin, 0, len(coins))
360
361 for _, coin := range coins {
362 list = append(list, api.Coin{TxID: coin.TxID, Vout: coin.Vout, Value: coin.Value, Height: coin.Height})
363 }
364
365 return api.CoinList{Coins: list}.Encode(), nil
366
367 case api.MethodMatches:
368 call, err := api.ParseChainRequest(request.Payload)
369
370 if err != nil {
371 return nil, err
372 }
373
374 return api.Heights{Heights: dom.Matches(call.Chain)}.Encode(), nil
375 }
376
377 return nil, errNoMethod
378 }
379
380 // statusOf renders a chain's state.
381 func statusOf(dom *domain.Domain, chain string) ([]byte, error) {
382 status, err := dom.Status(chain)
383
384 if err != nil {
385 return nil, err
386 }
387
388 return api.ChainStatus{
389 Chain: status.Chain,
390 Profile: status.Profile.Name,
391 HRP: status.Profile.HRP,
392 Account: status.Profile.Account,
393 HasWallet: status.HasWallet,
394 Unlocked: status.Unlocked,
395 Tip: status.Position.Tip,
396 Filters: status.Position.Filters,
397 Running: status.Position.Running,
398 Receive: status.Receive,
399 Change: status.Change,
400 Matches: uint32(status.Matches),
401 }.Encode(), nil
402 }
403
404 // setup is create or restore: the two ways a wallet begins.
405 func setup(dom *domain.Domain, request api.Frame) ([]byte, error) {
406 call, err := api.ParseSetupRequest(request.Method, request.Payload)
407
408 if err != nil {
409 return nil, err
410 }
411
412 if call.Passphrase == "" {
413 return nil, errors.New("wallet: a passphrase is required to seal the seed")
414 }
415
416 var created domain.Created
417
418 if request.Method == api.MethodCreate {
419 if call.Words != 12 && call.Words != 24 {
420 return nil, fmt.Errorf("wallet: a new seed is 12 or 24 words, not %d", call.Words)
421 }
422
423 created, err = dom.Create(call.Chain, int(call.Words), call.Passphrase)
424 } else {
425 if call.Phrase == "" {
426 return nil, errors.New("wallet: a restore needs the words")
427 }
428
429 created, err = dom.Restore(call.Chain, call.Phrase, call.Passphrase)
430 }
431
432 if err != nil {
433 return nil, err
434 }
435
436 return api.SetupResult{
437 Chain: created.Chain,
438 Mnemonic: created.Mnemonic,
439 Address: created.Address.Text,
440 Path: created.Address.Path.String(),
441 }.Encode(), nil
442 }
443
444 // addresses derives a run of addresses.
445 func addresses(dom *domain.Domain, request api.Frame) ([]byte, error) {
446 call, err := api.ParseAddressesRequest(request.Payload)
447
448 if err != nil {
449 return nil, err
450 }
451
452 derived, err := dom.Addresses(call.Chain, call.Change, call.From, uint32(call.Count))
453
454 if err != nil {
455 return nil, err
456 }
457
458 result := api.AddressesResult{
459 Addresses: make([]string, 0, len(derived)),
460 Indexes: make([]uint32, 0, len(derived)),
461 Paths: make([]string, 0, len(derived)),
462 }
463
464 for _, address := range derived {
465 result.Addresses = append(result.Addresses, address.Text)
466 result.Indexes = append(result.Indexes, address.Index)
467 result.Paths = append(result.Paths, address.Path.String())
468 }
469
470 return result.Encode(), nil
471 }
472
473 // console writes to the browser console, which is where a worker complains.
474 func console(message string) {
475 js.Global().Get("console").Call("warn", message)
476 }
477