main.go raw

   1  //go:build js && wasm
   2  
   3  // Command nostrino-channel is a worker that answers chain-data frames for a wallet.
   4  //
   5  // The page creates a MessageChannel and posts one end to this worker, then calls
   6  //
   7  //	nostrinoChannel.open(port, uri, chain, profileJSON)
   8  //
   9  // which returns an empty string on success or the reason it could not start. After that the
  10  // port carries binary frames only: a status, a sync, a script match, a filter range, a
  11  // cancel, a close. Nothing else is exported, because nothing else is needed — the wallet does
  12  // not call functions, it sends frames, and the vocabulary is the same one the gateway side
  13  // speaks.
  14  package main
  15  
  16  import (
  17  	"context"
  18  	"fmt"
  19  	"net"
  20  	"net/url"
  21  	"syscall/js"
  22  	"time"
  23  
  24  	"git.smesh.lol/nostrino/pkg/chain"
  25  	"git.smesh.lol/nostrino/pkg/chaindata"
  26  	"git.smesh.lol/nostrino/pkg/chainsource"
  27  	"git.smesh.lol/nostrino/pkg/idbstore"
  28  	"git.smesh.lol/nostrino/pkg/jsws"
  29  	"git.smesh.lol/nostrino/pkg/msgport"
  30  	"git.smesh.lol/nostrino/pkg/neutrino"
  31  	"git.smesh.lol/nostrino/pkg/relay"
  32  	"git.smesh.lol/nostrino/pkg/tunnel"
  33  	"git.smesh.lol/tiny-spawn"
  34  )
  35  
  36  func main() {
  37  	js.Global().Set("nostrinoChannel", js.ValueOf(map[string]any{
  38  		"open": js.FuncOf(jsOpen),
  39  	}))
  40  
  41  	select {}
  42  }
  43  
  44  // jsOpen starts a channel on a port and returns before the first frame arrives.
  45  func jsOpen(_ js.Value, args []js.Value) any {
  46  	if len(args) < 3 {
  47  		return "open(port, uri, chain, profileJSON) needs a port, a uri and a chain"
  48  	}
  49  
  50  	port, err := msgport.NewMessagePort(args[0])
  51  
  52  	if err != nil {
  53  		return err.Error()
  54  	}
  55  
  56  	chainName := args[2].String()
  57  
  58  	profileJSON := ""
  59  
  60  	if len(args) > 3 && args[3].Type() == js.TypeString {
  61  		profileJSON = args[3].String()
  62  	}
  63  
  64  	profile, err := profileFor(chainName, profileJSON)
  65  
  66  	if err != nil {
  67  		_ = port.Close()
  68  
  69  		return err.Error()
  70  	}
  71  
  72  	if err := profile.Validate(); err != nil {
  73  		_ = port.Close()
  74  
  75  		return "profile: " + err.Error()
  76  	}
  77  
  78  	uri, err := tunnel.ParseURI(args[1].String())
  79  
  80  	if err != nil {
  81  		_ = port.Close()
  82  
  83  		return "uri: " + err.Error()
  84  	}
  85  
  86  	go serve(port, uri, chainName, profile)
  87  
  88  	return ""
  89  }
  90  
  91  // profileFor builds the profile that verifies this chain.
  92  //
  93  // A profile from JSON is how the forked chain is described today: its network parameters are
  94  // Bitcoin's regtest ones, which is not a registered profile, and the name the relay handshake
  95  // asks for is the wallet's own ("xbt"), so the two are stated separately.
  96  func profileFor(chainName, profileJSON string) (*chain.Profile, error) {
  97  	if profileJSON != "" {
  98  		profile, err := chain.ParseProfile([]byte(profileJSON))
  99  
 100  		if err != nil {
 101  			return nil, fmt.Errorf("profile: %w", err)
 102  		}
 103  
 104  		return profile, nil
 105  	}
 106  
 107  	return chain.ByName(chainName)
 108  }
 109  
 110  // serve connects the light client and answers frames until the port is gone.
 111  func serve(port msgport.Port, uri *tunnel.URI, chainName string, profile *chain.Profile) {
 112  	store, idb := openStore(chainName, profile)
 113  
 114  	client, err := connect(uri, chainName, profile, store)
 115  
 116  	if err != nil {
 117  		console("error: nostrino channel: " + err.Error())
 118  		_ = port.Close()
 119  
 120  		return
 121  	}
 122  
 123  	source, err := chainsource.New(client)
 124  
 125  	if err != nil {
 126  		console("error: nostrino channel: " + err.Error())
 127  		client.Close()
 128  		_ = port.Close()
 129  
 130  		return
 131  	}
 132  
 133  	stop := tinyspawn.NewBreaker()
 134  
 135  	// Serve returns when the far end closes the port or the stop fires: the domain's relay
 136  	// reads the receive side, and a port that is gone closes it.
 137  	chaindata.Serve(chaindata.Data{Source: source}, chaindata.Chans{
 138  		Link: msgport.Duplex(port),
 139  		Stop: stop,
 140  	})
 141  
 142  	client.Close()
 143  
 144  	if idb != nil {
 145  		flush(idb)
 146  	}
 147  }
 148  
 149  // openStore opens the browser store, falling back to memory with a warning.
 150  //
 151  // A store that cannot open is a page that will rescan from the seed, not a wallet that loses
 152  // its coins, so the channel still starts; the warning is for the person debugging why a reload
 153  // took ten minutes.
 154  func openStore(chainName string, profile *chain.Profile) (neutrino.Store, *idbstore.Store) {
 155  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 156  	defer cancel()
 157  
 158  	// The database is named for the wallet's chain and the network, not the network alone.
 159  	// Both chains of a swap are regtest: naming the store "nostrino-regtest" gave them one
 160  	// database, so each stored the other's headers and a reload could not connect to its own
 161  	// peer ("peer chain does not connect to the stored chain").
 162  	idb, err := idbstore.Open(ctx, idbstore.Config{
 163  		DBName: "nostrino-" + chainName + "-" + profile.Name,
 164  		Chain:  chainName,
 165  	})
 166  
 167  	if err != nil {
 168  		console("warn: nostrino channel: no IndexedDB, syncing into memory: " + err.Error())
 169  
 170  		return neutrino.NewMemory(), nil
 171  	}
 172  
 173  	return idb, idb
 174  }
 175  
 176  // flush writes what the store holds before the worker goes away.
 177  func flush(idb *idbstore.Store) {
 178  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 179  	defer cancel()
 180  
 181  	if err := idb.Flush(ctx); err != nil {
 182  		console("warn: nostrino channel: flushing: " + err.Error())
 183  	}
 184  }
 185  
 186  // connect builds the light client over the relay tunnel.
 187  func connect(uri *tunnel.URI, chainName string, profile *chain.Profile, store neutrino.Store) (*neutrino.Client, error) {
 188  	return neutrino.New(neutrino.Config{
 189  		Profile: profile,
 190  		Store:   store,
 191  		Dial: func(ctx context.Context) (net.Conn, error) {
 192  			for _, raw := range uri.Bridges {
 193  				conn, err := jsws.DialStream(withChain(raw, chainName))
 194  
 195  				if err == nil {
 196  					return conn, nil
 197  				}
 198  			}
 199  
 200  			return tunnel.Dial(ctx, tunnel.DialConfig{
 201  				URI:   uri,
 202  				Chain: chainName,
 203  				TransportFactory: func(raw string) (relay.Transport, error) {
 204  					return jsws.Dial(raw)
 205  				},
 206  			})
 207  		},
 208  		RequireCompactFilters: true,
 209  		ReadTimeout:           120 * time.Second,
 210  	})
 211  }
 212  
 213  // withChain names the chain in a bridge URL, which is how one bridge serves more than one.
 214  func withChain(raw, chainName string) string {
 215  	parsed, err := url.Parse(raw)
 216  
 217  	if err != nil {
 218  		return raw
 219  	}
 220  
 221  	query := parsed.Query()
 222  	query.Set("chain", chainName)
 223  	parsed.RawQuery = query.Encode()
 224  
 225  	return parsed.String()
 226  }
 227  
 228  // console writes to the browser console, which is the only place a worker can complain.
 229  func console(message string) {
 230  	js.Global().Get("console").Call("warn", message)
 231  }
 232