//go:build js && wasm // Command nostrino-channel is a worker that answers chain-data frames for a wallet. // // The page creates a MessageChannel and posts one end to this worker, then calls // // nostrinoChannel.open(port, uri, chain, profileJSON) // // which returns an empty string on success or the reason it could not start. After that the // port carries binary frames only: a status, a sync, a script match, a filter range, a // cancel, a close. Nothing else is exported, because nothing else is needed — the wallet does // not call functions, it sends frames, and the vocabulary is the same one the gateway side // speaks. package main import ( "context" "fmt" "net" "net/url" "syscall/js" "time" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/chaindata" "git.smesh.lol/nostrino/pkg/chainsource" "git.smesh.lol/nostrino/pkg/idbstore" "git.smesh.lol/nostrino/pkg/jsws" "git.smesh.lol/nostrino/pkg/msgport" "git.smesh.lol/nostrino/pkg/neutrino" "git.smesh.lol/nostrino/pkg/relay" "git.smesh.lol/nostrino/pkg/tunnel" "git.smesh.lol/tiny-spawn" ) func main() { js.Global().Set("nostrinoChannel", js.ValueOf(map[string]any{ "open": js.FuncOf(jsOpen), })) select {} } // jsOpen starts a channel on a port and returns before the first frame arrives. func jsOpen(_ js.Value, args []js.Value) any { if len(args) < 3 { return "open(port, uri, chain, profileJSON) needs a port, a uri and a chain" } port, err := msgport.NewMessagePort(args[0]) if err != nil { return err.Error() } chainName := args[2].String() profileJSON := "" if len(args) > 3 && args[3].Type() == js.TypeString { profileJSON = args[3].String() } profile, err := profileFor(chainName, profileJSON) if err != nil { _ = port.Close() return err.Error() } if err := profile.Validate(); err != nil { _ = port.Close() return "profile: " + err.Error() } uri, err := tunnel.ParseURI(args[1].String()) if err != nil { _ = port.Close() return "uri: " + err.Error() } go serve(port, uri, chainName, profile) return "" } // profileFor builds the profile that verifies this chain. // // A profile from JSON is how the forked chain is described today: its network parameters are // Bitcoin's regtest ones, which is not a registered profile, and the name the relay handshake // asks for is the wallet's own ("xbt"), so the two are stated separately. func profileFor(chainName, profileJSON string) (*chain.Profile, error) { if profileJSON != "" { profile, err := chain.ParseProfile([]byte(profileJSON)) if err != nil { return nil, fmt.Errorf("profile: %w", err) } return profile, nil } return chain.ByName(chainName) } // serve connects the light client and answers frames until the port is gone. func serve(port msgport.Port, uri *tunnel.URI, chainName string, profile *chain.Profile) { store, idb := openStore(chainName, profile) client, err := connect(uri, chainName, profile, store) if err != nil { console("error: nostrino channel: " + err.Error()) _ = port.Close() return } source, err := chainsource.New(client) if err != nil { console("error: nostrino channel: " + err.Error()) client.Close() _ = port.Close() return } stop := tinyspawn.NewBreaker() // Serve returns when the far end closes the port or the stop fires: the domain's relay // reads the receive side, and a port that is gone closes it. chaindata.Serve(chaindata.Data{Source: source}, chaindata.Chans{ Link: msgport.Duplex(port), Stop: stop, }) client.Close() if idb != nil { flush(idb) } } // openStore opens the browser store, falling back to memory with a warning. // // A store that cannot open is a page that will rescan from the seed, not a wallet that loses // its coins, so the channel still starts; the warning is for the person debugging why a reload // took ten minutes. func openStore(chainName string, profile *chain.Profile) (neutrino.Store, *idbstore.Store) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // The database is named for the wallet's chain and the network, not the network alone. // Both chains of a swap are regtest: naming the store "nostrino-regtest" gave them one // database, so each stored the other's headers and a reload could not connect to its own // peer ("peer chain does not connect to the stored chain"). idb, err := idbstore.Open(ctx, idbstore.Config{ DBName: "nostrino-" + chainName + "-" + profile.Name, Chain: chainName, }) if err != nil { console("warn: nostrino channel: no IndexedDB, syncing into memory: " + err.Error()) return neutrino.NewMemory(), nil } return idb, idb } // flush writes what the store holds before the worker goes away. func flush(idb *idbstore.Store) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := idb.Flush(ctx); err != nil { console("warn: nostrino channel: flushing: " + err.Error()) } } // connect builds the light client over the relay tunnel. func connect(uri *tunnel.URI, chainName string, profile *chain.Profile, store neutrino.Store) (*neutrino.Client, error) { return neutrino.New(neutrino.Config{ Profile: profile, Store: store, Dial: func(ctx context.Context) (net.Conn, error) { for _, raw := range uri.Bridges { conn, err := jsws.DialStream(withChain(raw, chainName)) if err == nil { return conn, nil } } return tunnel.Dial(ctx, tunnel.DialConfig{ URI: uri, Chain: chainName, TransportFactory: func(raw string) (relay.Transport, error) { return jsws.Dial(raw) }, }) }, RequireCompactFilters: true, ReadTimeout: 120 * time.Second, }) } // withChain names the chain in a bridge URL, which is how one bridge serves more than one. func withChain(raw, chainName string) string { parsed, err := url.Parse(raw) if err != nil { return raw } query := parsed.Query() query.Set("chain", chainName) parsed.RawQuery = query.Encode() return parsed.String() } // console writes to the browser console, which is the only place a worker can complain. func console(message string) { js.Global().Get("console").Call("warn", message) }