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