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