//go:build js && wasm // Command tinywallet-wasm is the wallet worker: it owns the seed, the store and one // chain-data client per chain, and answers the page in frames. // // The page creates one MessageChannel to this worker and one to each nostrino worker, then // calls // // tinywallet.open(pagePort, [{name, network, port}, ...]) // // It returns "" on success or the reason it could not start. After that the page sends // request frames and receives result frames; nothing else crosses. The wallet does not call // functions on the page and the page does not reach into the wallet: the frames are the whole // contract, which is why a page and a test can both drive it. package main import ( "errors" "fmt" "sync" "syscall/js" "git.smesh.lol/nostrino/pkg/chaindata" "git.smesh.lol/nostrino/pkg/msgport" "git.smesh.lol/tiny-spawn" "git.smesh.lol/tinywallet/pkg/api" "git.smesh.lol/tinywallet/pkg/domain" "git.smesh.lol/tinywallet/pkg/store" "git.smesh.lol/tinywallet/pkg/wallet" ) func main() { js.Global().Set("tinywallet", js.ValueOf(map[string]any{ "open": js.FuncOf(jsOpen), })) select {} } // spec is one chain the page wants the wallet to drive. type spec struct { // name is the wallet's chain name: "btc" or "blake". name string // network is the chain profile name: "btc", "regtest", "testnet3", "signet". network string // port is the wallet's end of the channel to that chain's nostrino worker. port msgport.Port } // jsOpen starts the wallet. func jsOpen(_ js.Value, args []js.Value) any { if len(args) < 2 { return "open(pagePort, chains) needs a page port and a chain list" } pagePort, err := msgport.NewMessagePort(args[0]) if err != nil { return err.Error() } specs, err := parseSpecs(args[1]) if err != nil { _ = pagePort.Close() return err.Error() } cache, err := openStore() if err != nil { _ = pagePort.Close() return err.Error() } dom, err := domain.New(cache) if err != nil { return err.Error() } stop := tinyspawn.NewBreaker() clients := map[string]*chaindata.Client{} for _, link := range specs { profile, err := wallet.ProfileFor(link.name, link.network) if err != nil { return err.Error() } client := chaindata.NewClient(link.name, msgport.Duplex(link.port), stop) clients[link.name] = client if err := dom.Add(link.name, domain.Link{Profile: profile, Client: client}); err != nil { return err.Error() } } go serve(pagePort, dom, clients) return "" } // openStore opens the browser store. // // A failure here is fatal to the worker, unlike on the chain side: a wallet whose store cannot // open would answer "no wallet" for a funded seed, and that is the one lie this code must not // tell. func openStore() (*store.Cache, error) { persist, err := store.OpenIndexedDB("tinywallet") if err != nil { return nil, fmt.Errorf("store: %w", err) } cache, err := store.Open(persist) if err != nil { return nil, fmt.Errorf("store: %w", err) } return cache, nil } // parseSpecs reads the chain list the page passes. func parseSpecs(value js.Value) ([]spec, error) { if value.Type() != js.TypeObject { return nil, errors.New("wallet: the chain list is not an array") } length := value.Length() if length == 0 { return nil, errors.New("wallet: no chains were given") } out := make([]spec, 0, length) for index := 0; index < length; index++ { entry := value.Index(index) name := entry.Get("name").String() network := entry.Get("network").String() if name == "" || network == "" { return nil, fmt.Errorf("wallet: chain %d needs a name and a network", index) } port, err := msgport.NewMessagePort(entry.Get("port")) if err != nil { return nil, fmt.Errorf("wallet: chain %q: %w", name, err) } out = append(out, spec{name: name, network: network, port: port}) } return out, nil } // serve reads the page's frames and answers them. // // Each request runs in its own goroutine so a sync does not hold up a status query, and the // map of in-flight requests is what lets a cancel reach the chain that is working. func serve(port msgport.Port, dom *domain.Domain, clients map[string]*chaindata.Client) { var ( mu sync.Mutex active = map[uint32]string{} ) for raw := range port.Receive() { request, err := api.Decode(raw) if err != nil { continue } switch request.Kind { case api.KindRequest: mu.Lock() active[request.ID] = requestChain(dom, request) chain := active[request.ID] mu.Unlock() go func(request api.Frame, chain string) { answer := handle(dom, request) mu.Lock() delete(active, request.ID) mu.Unlock() if err := port.Send(answer); err != nil { console("warn: wallet: sending an answer: " + err.Error()) } _ = chain }(request, chain) case api.KindCancel: mu.Lock() chain, ok := active[request.ID] delete(active, request.ID) mu.Unlock() if !ok { continue } // The only request that runs long enough to cancel is a sync, and cancelling it // is cancelling the chain's work: the client withdraws what it has in flight. if client, ok := clients[chain]; ok { client.CancelPending() } case api.KindClose: return } } } // requestChain names the chain a request is about, without decoding its payload twice. func requestChain(dom *domain.Domain, request api.Frame) string { payload := request.Payload if len(payload) < 2 { return "" } // Every request payload starts with the chain name, so the name is readable the same way // for all of them. text, _, err := api.ReadText(payload) if err != nil { return "" } for _, name := range dom.Names() { if name == text { return text } } return "" } // handle runs one request and renders its answer. func handle(dom *domain.Domain, request api.Frame) []byte { answer := api.Frame{Kind: api.KindResult, Method: request.Method, ID: request.ID} payload, err := dispatch(dom, request) switch { case errors.Is(err, api.ErrBadFrame): answer.Status = api.StatusMalformed answer.Payload = api.PutText(nil, err.Error()) case errors.Is(err, errNoMethod): answer.Status = api.StatusUnknownMethod answer.Payload = api.PutText(nil, "wallet: no method "+request.Method.String()) case err != nil: answer.Status = api.StatusFailed answer.Payload = api.PutText(nil, err.Error()) default: answer.Status = api.StatusOK answer.Payload = payload } encoded, err := api.Encode(answer) if err != nil { fallback, _ := api.Encode(api.Message(api.StatusMalformed, request.ID, err.Error())) return fallback } return encoded } // errNoMethod is a method this build does not have. var errNoMethod = errors.New("wallet: no such method") // dispatch routes a request to the domain. func dispatch(dom *domain.Domain, request api.Frame) ([]byte, error) { switch request.Method { case api.MethodChains: return api.Chains{Names: dom.Names()}.Encode(), nil case api.MethodStatus: call, err := api.ParseChainRequest(request.Payload) if err != nil { return nil, err } return statusOf(dom, call.Chain) case api.MethodCreate, api.MethodRestore: return setup(dom, request) case api.MethodUnlock: call, err := api.ParseUnlockRequest(request.Payload) if err != nil { return nil, err } if err := dom.Unlock(call.Chain, call.Passphrase); err != nil { return nil, err } return api.PutText(nil, ""), nil case api.MethodLock: call, err := api.ParseChainRequest(request.Payload) if err != nil { return nil, err } dom.Lock(call.Chain) return api.PutText(nil, ""), nil case api.MethodAddresses: return addresses(dom, request) case api.MethodSync: call, err := api.ParseSyncRequest(request.Payload) if err != nil { return nil, err } report, err := dom.Sync(call.Chain, call.Lookahead) if err != nil { return nil, err } return api.SyncResult{ From: report.From, Tip: report.Position.Tip, Filters: report.Position.Filters, Scripts: uint32(report.Scripts), Matched: report.Matched, New: report.New, }.Encode(), nil case api.MethodUnspent: coins, err := dom.Unspent() if err != nil { return nil, err } list := make([]api.Coin, 0, len(coins)) for _, coin := range coins { list = append(list, api.Coin{TxID: coin.TxID, Vout: coin.Vout, Value: coin.Value, Height: coin.Height}) } return api.CoinList{Coins: list}.Encode(), nil case api.MethodMatches: call, err := api.ParseChainRequest(request.Payload) if err != nil { return nil, err } return api.Heights{Heights: dom.Matches(call.Chain)}.Encode(), nil } return nil, errNoMethod } // statusOf renders a chain's state. func statusOf(dom *domain.Domain, chain string) ([]byte, error) { status, err := dom.Status(chain) if err != nil { return nil, err } return api.ChainStatus{ Chain: status.Chain, Profile: status.Profile.Name, HRP: status.Profile.HRP, Account: status.Profile.Account, HasWallet: status.HasWallet, Unlocked: status.Unlocked, Tip: status.Position.Tip, Filters: status.Position.Filters, Running: status.Position.Running, Receive: status.Receive, Change: status.Change, Matches: uint32(status.Matches), }.Encode(), nil } // setup is create or restore: the two ways a wallet begins. func setup(dom *domain.Domain, request api.Frame) ([]byte, error) { call, err := api.ParseSetupRequest(request.Method, request.Payload) if err != nil { return nil, err } if call.Passphrase == "" { return nil, errors.New("wallet: a passphrase is required to seal the seed") } var created domain.Created if request.Method == api.MethodCreate { if call.Words != 12 && call.Words != 24 { return nil, fmt.Errorf("wallet: a new seed is 12 or 24 words, not %d", call.Words) } created, err = dom.Create(call.Chain, int(call.Words), call.Passphrase) } else { if call.Phrase == "" { return nil, errors.New("wallet: a restore needs the words") } created, err = dom.Restore(call.Chain, call.Phrase, call.Passphrase) } if err != nil { return nil, err } return api.SetupResult{ Chain: created.Chain, Mnemonic: created.Mnemonic, Address: created.Address.Text, Path: created.Address.Path.String(), }.Encode(), nil } // addresses derives a run of addresses. func addresses(dom *domain.Domain, request api.Frame) ([]byte, error) { call, err := api.ParseAddressesRequest(request.Payload) if err != nil { return nil, err } derived, err := dom.Addresses(call.Chain, call.Change, call.From, uint32(call.Count)) if err != nil { return nil, err } result := api.AddressesResult{ Addresses: make([]string, 0, len(derived)), Indexes: make([]uint32, 0, len(derived)), Paths: make([]string, 0, len(derived)), } for _, address := range derived { result.Addresses = append(result.Addresses, address.Text) result.Indexes = append(result.Indexes, address.Index) result.Paths = append(result.Paths, address.Path.String()) } return result.Encode(), nil } // console writes to the browser console, which is where a worker complains. func console(message string) { js.Global().Get("console").Call("warn", message) }