diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 232c11832c..46e54bc52a 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -11,6 +11,7 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "encoding/json" "errors" "fmt" "math" @@ -73,7 +74,7 @@ const ( walletTypeGeth = "geth" walletTypeRPC = "rpc" - providersKey = "providers" + providersKey = "providersv1" // confCheckTimeout is the amount of time allowed to check for // confirmations. Testing on testnet has shown spikes up to 2.5 @@ -101,13 +102,13 @@ var ( } RPCOpts = []*asset.ConfigOption{ { - Key: providersKey, - DisplayName: "Provider", - Description: "Specify one or more providers. For infrastructure " + + Key: providersKey, + RepeatableDisplayName: []string{"Provider", "JWT secret"}, + RepeatableDescription: []string{"Specify one or more providers. For infrastructure " + "providers, use an https address. Only url-based authentication " + "is supported. For a local node, use the filepath to an IPC file.", - Repeatable: providerDelimiter, - Required: true, + "Specify a jwt secret if communication with a geth full node over ws."}, + Required: true, }, } // WalletInfo defines some general information about a Ethereum wallet. @@ -516,6 +517,31 @@ func CreateWallet(cfg *asset.CreateWalletParams) error { return createWallet(cfg, false) } +// endpointsFromSettings parses endpoints from the setting map. Endpoints are +// stored as and array of and array of strings. +func endpointsFromSettings(settings map[string]string) ([]endpoint, error) { + providerDef := settings[providersKey] + if len(providerDef) == 0 { + return nil, errors.New("no providers specified") + } + var values [][]string + err := json.Unmarshal([]byte(providerDef), &values) + if err != nil { + return nil, err + } + endpoints := make([]endpoint, len(values)) + for i, v := range values { + switch len(v) { + case 2: + endpoints[i].jwt = v[1] + fallthrough + case 1: + endpoints[i].addr = v[0] + } + } + return endpoints, nil +} + func createWallet(createWalletParams *asset.CreateWalletParams, skipConnect bool) error { switch createWalletParams.Type { case walletTypeGeth: @@ -558,12 +584,10 @@ func createWallet(createWalletParams *asset.CreateWalletParams, skipConnect bool case walletTypeRPC: // Check that we can connect to all endpoints. - providerDef := createWalletParams.Settings[providersKey] - if len(providerDef) == 0 { - return errors.New("no providers specified") + endpoints, err := endpointsFromSettings(createWalletParams.Settings) + if err != nil { + return fmt.Errorf("unable to read endpoints: %v", err) } - endpoints := strings.Split(providerDef, providerDelimiter) - n := len(endpoints) // TODO: This procedure may actually work for walletTypeGeth too. ks := keystore.NewKeyStore(filepath.Join(walletDir, "keystore"), keystore.LightScryptN, keystore.LightScryptP) @@ -577,14 +601,15 @@ func createWallet(createWalletParams *asset.CreateWalletParams, skipConnect bool ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - var unknownEndpoints []string + var unknownEndpoints []endpoint - for _, endpoint := range endpoints { - known, compliant := providerIsCompliant(endpoint) + for _, ep := range endpoints { + addr := ep.addr + known, compliant := providerIsCompliant(addr) if known && !compliant { - return fmt.Errorf("provider %q is known to have an insufficient API for DEX", endpoint) + return fmt.Errorf("provider %q is known to have an insufficient API for DEX", addr) } else if !known { - unknownEndpoints = append(unknownEndpoints, endpoint) + unknownEndpoints = append(unknownEndpoints, ep) } } @@ -598,8 +623,8 @@ func createWallet(createWalletParams *asset.CreateWalletParams, skipConnect bool p.ec.Close() } }() - if len(providers) != n { - return fmt.Errorf("Could not connect to all providers") + if len(providers) != len(endpoints) { + return errors.New("could not connect to all providers") } if err := checkProvidersCompliance(ctx, walletDir, providers, createWalletParams.Logger); err != nil { return err @@ -728,7 +753,10 @@ func (w *ETHWallet) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) // } return nil, asset.ErrWalletTypeDisabled case walletTypeRPC: - endpoints := strings.Split(w.settings[providersKey], " ") + endpoints, err := endpointsFromSettings(w.settings) + if err != nil { + return nil, fmt.Errorf("unable to read endpoints: %v", err) + } ethCfg, err := ethChainConfig(w.net) if err != nil { return nil, err @@ -738,7 +766,7 @@ func (w *ETHWallet) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) // Point to a harness node on simnet, if not specified. if w.net == dex.Simnet && len(endpoints) == 0 { u, _ := user.Current() - endpoints = append(endpoints, filepath.Join(u.HomeDir, "dextest", "eth", "beta", "node", "geth.ipc")) + endpoints = append(endpoints, endpoint{addr: filepath.Join(u.HomeDir, "dextest", "eth", "beta", "node", "geth.ipc")}) } cl, err = newMultiRPCClient(w.dir, endpoints, w.log.SubLogger("RPC"), chainConfig, big.NewInt(chainIDs[w.net]), w.net) diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 5d386787eb..226920763d 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -2939,7 +2939,7 @@ func TestDriverOpen(t *testing.T) { logger := dex.StdOutLogger("ETHTEST", dex.LevelOff) tmpDir := t.TempDir() - settings := map[string]string{providersKey: "a.ipc"} + settings := map[string]string{providersKey: `[["a.ipc",""]]`} err := createWallet(&asset.CreateWalletParams{ Type: walletTypeRPC, Seed: encode.RandomBytes(32), @@ -2990,7 +2990,7 @@ func TestDriverExists(t *testing.T) { drv := &Driver{} tmpDir := t.TempDir() - settings := map[string]string{providersKey: "a.ipc"} + settings := map[string]string{providersKey: `[["a.ipc",""]]`} // no wallet exists, err := drv.Exists(walletTypeRPC, tmpDir, settings, dex.Simnet) @@ -4557,7 +4557,7 @@ func testMaxSwapRedeemLots(t *testing.T, assetID uint32) { logger := dex.StdOutLogger("ETHTEST", dex.LevelOff) tmpDir := t.TempDir() - settings := map[string]string{providersKey: "a.ipc"} + settings := map[string]string{providersKey: `[["a.ipc",""]]`} err := createWallet(&asset.CreateWalletParams{ Type: walletTypeRPC, Seed: encode.RandomBytes(32), diff --git a/client/asset/eth/multirpc.go b/client/asset/eth/multirpc.go index 7da772a378..36b67c9032 100644 --- a/client/asset/eth/multirpc.go +++ b/client/asset/eth/multirpc.go @@ -14,6 +14,7 @@ import ( "fmt" "math/big" "math/rand" + "net/http" "net/url" "os" "path/filepath" @@ -206,7 +207,7 @@ func (p *provider) subscribeHeaders(ctx context.Context, sub ethereum.Subscripti return sub, nil } if time.Since(lastWarning) > 5*time.Minute { - log.Warnf("can't resubscribe to %q headers: %v", err) + log.Warnf("can't resubscribe to %q headers: %v", p.host, err) } select { case <-time.After(time.Second * 30): @@ -254,7 +255,7 @@ func (p *provider) subscribeHeaders(ctx context.Context, sub ethereum.Subscripti return } log.Errorf("%q header subscription error: %v", p.host, err) - log.Info("Attempting to resubscribe to %q block headers", p.host) + log.Infof("Attempting to resubscribe to %q block headers", p.host) sub, err = newSub() if err != nil { // context cancelled return @@ -282,6 +283,11 @@ type receiptRecord struct { confirmed bool } +type endpoint struct { + addr string + jwt string +} + // multiRPCClient is an ethFetcher backed by one or more public RPC providers. type multiRPCClient struct { cfg *params.ChainConfig @@ -290,7 +296,7 @@ type multiRPCClient struct { chainID *big.Int providerMtx sync.Mutex - endpoints []string + endpoints []endpoint providers []*provider lastNonce struct { @@ -316,7 +322,7 @@ type multiRPCClient struct { var _ ethFetcher = (*multiRPCClient)(nil) -func newMultiRPCClient(dir string, endpoints []string, log dex.Logger, cfg *params.ChainConfig, chainID *big.Int, net dex.Network) (*multiRPCClient, error) { +func newMultiRPCClient(dir string, endpoints []endpoint, log dex.Logger, cfg *params.ChainConfig, chainID *big.Int, net dex.Network) (*multiRPCClient, error) { walletDir := getWalletDir(dir, net) creds, err := pathCredentials(filepath.Join(walletDir, "keystore")) if err != nil { @@ -340,7 +346,7 @@ func newMultiRPCClient(dir string, endpoints []string, log dex.Logger, cfg *para // list of providers that were successfully connected. It is not an error for a // connection to fail. The caller can infer failed connections from the length // and contents of the returned provider list. -func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, chainID *big.Int) ([]*provider, error) { +func connectProviders(ctx context.Context, endpoints []endpoint, log dex.Logger, chainID *big.Int) ([]*provider, error) { providers := make([]*provider, 0, len(endpoints)) var success bool @@ -352,7 +358,7 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c } }() - for _, endpoint := range endpoints { + for _, ep := range endpoints { // First try to get a websocket connection. Websockets have a header // feed, so are much preferred to http connections. So much so, that // we'll do some path inspection here and make an attempt to find a @@ -362,10 +368,14 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c var sub ethereum.Subscription var h chan *types.Header host := providerIPC - if !strings.HasSuffix(endpoint, ".ipc") { - wsURL, err := url.Parse(endpoint) + addr := ep.addr + if strings.HasSuffix(addr, ".ipc") { + // Clean file path. + addr = dex.CleanAndExpandPath(addr) + } else { + wsURL, err := url.Parse(addr) if err != nil { - return nil, fmt.Errorf("Failed to parse url %q", endpoint) + return nil, fmt.Errorf("Failed to parse url %q", addr) } host = wsURL.Host ogScheme := wsURL.Scheme @@ -376,7 +386,7 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c wsURL.Scheme = "ws" case "ws", "wss": default: - return nil, fmt.Errorf("unknown scheme for endpoint %q: %q", endpoint, wsURL.Scheme) + return nil, fmt.Errorf("unknown scheme for endpoint %q: %q", addr, wsURL.Scheme) } replaced := ogScheme != wsURL.Scheme @@ -392,7 +402,18 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c host = providerRivetCloud } - rpcClient, err = rpc.DialWebsocket(ctx, wsURL.String(), "") + if ep.jwt == "" { + rpcClient, err = rpc.DialWebsocket(ctx, wsURL.String(), "") + } else { + // Geth clients should always be able to get a + // websocket connection, making http unnecessary. + var authFn func(h http.Header) error + authFn, err = dexeth.JWTHTTPAuthFn(ep.jwt) + if err != nil { + return nil, fmt.Errorf("unable to create auth function: %v", err) + } + rpcClient, err = rpc.DialOptions(ctx, wsURL.String(), rpc.WithHTTPAuth(authFn)) + } if err == nil { ec = ethclient.NewClient(rpcClient) h = make(chan *types.Header, 8) @@ -410,7 +431,7 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c if replaced { log.Debugf("couldn't get a websocket connection for %q (original scheme: %q) (OK)", wsURL, ogScheme) } else { - log.Errorf("failed to get websocket connection to %q. attempting http(s) fallback: error = %v", endpoint, err) + log.Errorf("failed to get websocket connection to %q. attempting http(s) fallback: error = %v", addr, err) } } } @@ -418,9 +439,9 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c // path discrimination, so I won't even try to validate the protocol. if ec == nil { var err error - rpcClient, err = rpc.Dial(endpoint) + rpcClient, err = rpc.DialContext(ctx, addr) if err != nil { - log.Errorf("error creating http client for %q: %v", endpoint, err) + log.Errorf("error creating http client for %q: %v", addr, err) continue } ec = ethclient.NewClient(rpcClient) @@ -431,12 +452,12 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c if err != nil { // If we can't get a header, don't use this provider. ec.Close() - log.Errorf("Failed to get chain ID from %q: %v", endpoint, err) + log.Errorf("Failed to get chain ID from %q: %v", addr, err) continue } if chainID.Cmp(reportedChainID) != 0 { ec.Close() - log.Errorf("%q reported wrong chain ID. expected %d, got %d", endpoint, chainID, reportedChainID) + log.Errorf("%q reported wrong chain ID. expected %d, got %d", addr, chainID, reportedChainID) continue } @@ -444,7 +465,7 @@ func connectProviders(ctx context.Context, endpoints []string, log dex.Logger, c if err != nil { // If we can't get a header, don't use this provider. ec.Close() - log.Errorf("Failed to get header from %q: %v", endpoint, err) + log.Errorf("Failed to get header from %q: %v", addr, err) continue } @@ -547,11 +568,10 @@ func (m *multiRPCClient) voidUnusedNonce() { } func (m *multiRPCClient) reconfigure(ctx context.Context, settings map[string]string) error { - providerDef := settings[providersKey] - if len(providerDef) == 0 { - return errors.New("no providers specified") + endpoints, err := endpointsFromSettings(settings) + if err != nil { + return fmt.Errorf("unable to read endpoints: %v", err) } - endpoints := strings.Split(providerDef, " ") providers, err := connectProviders(ctx, endpoints, m.log, m.chainID) if err != nil { return err diff --git a/client/asset/eth/multirpc_live_test.go b/client/asset/eth/multirpc_live_test.go index 49ef056fbd..d70130582d 100644 --- a/client/asset/eth/multirpc_live_test.go +++ b/client/asset/eth/multirpc_live_test.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "time" @@ -25,8 +24,9 @@ import ( ) const ( - deltaHTTPPort = "38556" - deltaWSPort = "38557" + alphaAuthedPort = "8552" + betaAuthedPort = "8553" + jwtSecret = "0x45747261485f394e52346574347a4d78527941734f30512d4e32383dbabababa" ) var ( @@ -46,7 +46,7 @@ func mine(ctx context.Context) error { return err } -func testEndpoint(endpoints []string, syncBlocks uint64, tFunc func(context.Context, *multiRPCClient)) error { +func testEndpoint(endpoints []endpoint, syncBlocks uint64, tFunc func(context.Context, *multiRPCClient)) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -102,20 +102,21 @@ func testEndpoint(endpoints []string, syncBlocks uint64, tFunc func(context.Cont return nil } +// NOTE: This will be upgraded to websocket and does not test a http path in earnest. func TestHTTP(t *testing.T) { - if err := testEndpoint([]string{"http://localhost:" + deltaHTTPPort}, 2, nil); err != nil { + if err := testEndpoint([]endpoint{{addr: "http://localhost:" + alphaAuthedPort, jwt: jwtSecret}}, 2, nil); err != nil { t.Fatal(err) } } func TestWS(t *testing.T) { - if err := testEndpoint([]string{"ws://localhost:" + deltaWSPort}, 2, nil); err != nil { + if err := testEndpoint([]endpoint{{addr: "ws://localhost:" + betaAuthedPort, jwt: jwtSecret}}, 2, nil); err != nil { t.Fatal(err) } } func TestWSTxLogs(t *testing.T) { - if err := testEndpoint([]string{"ws://localhost:" + deltaWSPort}, 2, func(ctx context.Context, cl *multiRPCClient) { + if err := testEndpoint([]endpoint{{addr: "ws://localhost:" + alphaAuthedPort, jwt: jwtSecret}}, 2, func(ctx context.Context, cl *multiRPCClient) { for i := 0; i < 3; i++ { time.Sleep(time.Second) harnessCmd(ctx, "./sendtoaddress", cl.creds.addr.String(), "1") @@ -130,9 +131,9 @@ func TestWSTxLogs(t *testing.T) { } func TestSimnetMultiRPCClient(t *testing.T) { - endpoints := []string{ - "ws://localhost:" + deltaWSPort, - "http://localhost:" + deltaHTTPPort, + endpoints := []endpoint{ + {addr: "ws://localhost:" + alphaAuthedPort, jwt: jwtSecret}, + {addr: "http://localhost:" + betaAuthedPort, jwt: jwtSecret}, // NOTE: Will be upgraded to a websocket. } nonceProviderStickiness = time.Second / 2 @@ -238,9 +239,13 @@ func TestMonitorMainnet(t *testing.T) { func testMonitorNet(t *testing.T, net dex.Network) { providerFile := readProviderFile(t, net) + endpoints := make([]endpoint, 0, 1) + for _, addr := range providerFile.Providers { + endpoints = append(endpoints, endpoint{addr: addr}) + } dir, _ := os.MkdirTemp("", "") - cl, err := tRPCClient(dir, providerFile.Seed, providerFile.Providers, net, true) + cl, err := tRPCClient(dir, providerFile.Seed, endpoints, net, true) if err != nil { t.Fatal(err) } @@ -255,13 +260,13 @@ func testMonitorNet(t *testing.T, net dex.Network) { } func TestRPC(t *testing.T) { - endpoint := os.Getenv("PROVIDER") - if endpoint == "" { + addr := os.Getenv("PROVIDER") + if addr == "" { t.Fatalf("specify a provider in the PROVIDER environmental variable") } dir, _ := os.MkdirTemp("", "") defer os.RemoveAll(dir) - cl, err := tRPCClient(dir, encode.RandomBytes(32), []string{endpoint}, dex.Mainnet, true) + cl, err := tRPCClient(dir, encode.RandomBytes(32), []endpoint{{addr: addr}}, dex.Mainnet, true) if err != nil { t.Fatal(err) } @@ -292,12 +297,12 @@ var freeServers = []string{ } func TestFreeServers(t *testing.T) { - runTest := func(endpoint string) error { + runTest := func(ep endpoint) error { dir, _ := os.MkdirTemp("", "") defer os.RemoveAll(dir) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - cl, err := tRPCClient(dir, encode.RandomBytes(32), []string{endpoint}, dex.Mainnet, true) + cl, err := tRPCClient(dir, encode.RandomBytes(32), []endpoint{ep}, dex.Mainnet, true) if err != nil { return fmt.Errorf("tRPCClient error: %v", err) } @@ -309,18 +314,19 @@ func TestFreeServers(t *testing.T) { if err := tt.f(p); err != nil { return fmt.Errorf("%q error: %v", tt.name, err) } - fmt.Printf("#### %q passed %q \n", endpoint, tt.name) + fmt.Printf("#### %q passed %q \n", ep.addr, tt.name) } return nil }) } passes, fails := make([]string, 0), make(map[string]error, 0) - for _, endpoint := range freeServers { - if err := runTest(endpoint); err != nil { - fails[endpoint] = err + for _, addr := range freeServers { + ep := endpoint{addr: addr} + if err := runTest(ep); err != nil { + fails[addr] = err } else { - passes = append(passes, endpoint) + passes = append(passes, addr) } } for _, pass := range passes { @@ -333,12 +339,16 @@ func TestFreeServers(t *testing.T) { func TestMainnetCompliance(t *testing.T) { providerFile := readProviderFile(t, dex.Mainnet) + endpoints := make([]endpoint, 0, len(providerFile.Providers)) + for _, addr := range providerFile.Providers { + endpoints = append(endpoints, endpoint{addr: addr}) + } dir, _ := os.MkdirTemp("", "") ctx, cancel := context.WithCancel(context.Background()) defer cancel() log := dex.StdOutLogger("T", dex.LevelTrace) - providers, err := connectProviders(ctx, providerFile.Providers, log, big.NewInt(chainIDs[dex.Mainnet])) + providers, err := connectProviders(ctx, endpoints, log, big.NewInt(chainIDs[dex.Mainnet])) if err != nil { t.Fatal(err) } @@ -348,14 +358,23 @@ func TestMainnetCompliance(t *testing.T) { } } -func tRPCClient(dir string, seed []byte, endpoints []string, net dex.Network, skipConnect bool) (*multiRPCClient, error) { +func tRPCClient(dir string, seed []byte, endpoints []endpoint, net dex.Network, skipConnect bool) (*multiRPCClient, error) { log := dex.StdOutLogger("T", dex.LevelTrace) + var providersSlice [][]string + for _, ep := range endpoints { + valueSet := []string{ep.addr, ep.jwt} + providersSlice = append(providersSlice, valueSet) + } + providers, err := json.Marshal(providersSlice) + if err != nil { + return nil, err + } if err := createWallet(&asset.CreateWalletParams{ Type: walletTypeRPC, Seed: seed, Pass: []byte("abc"), Settings: map[string]string{ - "providers": strings.Join(endpoints, " "), + providersKey: string(providers), }, DataDir: dir, Net: net, diff --git a/client/asset/eth/nodeclient_harness_test.go b/client/asset/eth/nodeclient_harness_test.go index 2bf663fa8d..0ee2bfb34b 100644 --- a/client/asset/eth/nodeclient_harness_test.go +++ b/client/asset/eth/nodeclient_harness_test.go @@ -1,7 +1,18 @@ //go:build harness && lgpl -// This test requires that the simnet harness be running. Some tests will -// alternatively work on testnet. +// This test requires that the simnet harness be running. Tests also work on +// siment but require some extra setup. +// +// If you are testing on testnet, you must specify the provider and/or jwt secret +// in the testnet-credentials.json file. +// +// example of testnet-credentials.json file: +// { +// "key0": "0000000000000000000000000000000000000000000000000000000000000000", +// "key1": "1111111111111111111111111111111111111111111111111111111111111111", +// "provider": "ws://127.0.0.1:8551", +// "jwt": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +// } // // NOTE: These test reuse a light node that lives in the dextest folders. // However, when recreating the test database for every test, the nonce used @@ -11,10 +22,6 @@ // fail, and sometimes the redeem and refund functions to also fail. This could // be a problem in the future if a user restores from seed. Punting on this // particular problem for now. -// -// TODO: Running these tests many times on simnet eventually results in all -// transactions returning "unexpected error for test ok: exceeds block gas -// limit". Find out why that is. package eth @@ -105,11 +112,9 @@ var ( // block currently. Is set in code to testnetSecPerBlock if runing on // testnet. secPerBlock = time.Second - // If you are testing on testnet, you must specify the rpcNode. You can also - // specify it in the testnet-credentials.json file. - rpcNode string // useRPC can be set to true to test the RPC clients. - useRPC bool + useRPC bool + rpcNode endpoint // isTestnet can be set to true to perform tests on the goerli testnet. // May need some setup including sending testnet coins to the addresses @@ -268,14 +273,14 @@ out: return nil } -func prepareRPCClient(name, dataDir, endpoint string, net dex.Network) (*multiRPCClient, *accounts.Account, error) { +func prepareRPCClient(name, dataDir string, ep *endpoint, net dex.Network) (*multiRPCClient, *accounts.Account, error) { ethCfg, err := ethChainConfig(net) if err != nil { return nil, nil, err } cfg := ethCfg.Genesis.Config - c, err := newMultiRPCClient(dataDir, []string{endpoint}, tLogger.SubLogger(name), cfg, big.NewInt(chainIDs[net]), net) + c, err := newMultiRPCClient(dataDir, []endpoint{{addr: ep.addr, jwt: ep.jwt}}, tLogger.SubLogger(name), cfg, big.NewInt(chainIDs[net]), net) if err != nil { return nil, nil, fmt.Errorf("(%s) newNodeClient error: %v", name, err) } @@ -285,11 +290,11 @@ func prepareRPCClient(name, dataDir, endpoint string, net dex.Network) (*multiRP return c, c.creds.acct, nil } -func rpcEndpoints(net dex.Network) (string, string) { +func rpcEndpoints(net dex.Network) (initiator, participant *endpoint) { if net == dex.Testnet { - return rpcNode, rpcNode + return &rpcNode, &rpcNode } - return alphaIPCFile, betaIPCFile + return &endpoint{addr: alphaIPCFile}, &endpoint{addr: betaIPCFile} } func prepareTestRPCClients(initiatorDir, participantDir string, net dex.Network) (err error) { @@ -605,25 +610,24 @@ func useTestnet() error { isTestnet = true b, err := os.ReadFile(testnetCredentialsPath) if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("error reading credentials file: %v", err) - } - } else { - var creds struct { - Key0 string `json:"key0"` - Key1 string `json:"key1"` - Provider string `json:"provider"` - } - if err := json.Unmarshal(b, &creds); err != nil { - return fmt.Errorf("error parsing credentials file: %v", err) - } - if creds.Key0 == "" || creds.Key1 == "" { - return fmt.Errorf("must provide both keys in testnet credentials file") - } - testnetWalletSeed = creds.Key0 - testnetParticipantWalletSeed = creds.Key1 - rpcNode = creds.Provider + return fmt.Errorf("error reading credentials file: %v", err) + } + var creds struct { + Key0 string `json:"key0"` + Key1 string `json:"key1"` + Provider string `json:"provider"` + JWT string `json:"jwt"` + } + if err := json.Unmarshal(b, &creds); err != nil { + return fmt.Errorf("error parsing credentials file: %v", err) + } + if creds.Key0 == "" || creds.Key1 == "" { + return fmt.Errorf("must provide both keys in testnet credentials file") } + testnetWalletSeed = creds.Key0 + testnetParticipantWalletSeed = creds.Key1 + rpcNode.addr = creds.Provider + rpcNode.jwt = creds.JWT return nil } @@ -631,7 +635,9 @@ func TestMain(m *testing.M) { dexeth.MaybeReadSimnetAddrs() flag.BoolVar(&isTestnet, "testnet", false, "use testnet") - flag.BoolVar(&useRPC, "rpc", false, "use RPC") + // NOTE: Internal clients are currently disabled because light clients + // do not work since the merge. Default this to false when they are fixed. + flag.BoolVar(&useRPC, "rpc", true, "use RPC") flag.Parse() if isTestnet { @@ -666,7 +672,7 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func setupWallet(walletDir, seed, listenAddress, rpcAddr string, net dex.Network) error { +func setupWallet(walletDir, seed, listenAddress string, ep *endpoint, net dex.Network) error { walletType := walletTypeGeth settings := map[string]string{ "nodelistenaddr": listenAddress, @@ -674,7 +680,7 @@ func setupWallet(walletDir, seed, listenAddress, rpcAddr string, net dex.Network if useRPC { walletType = walletTypeRPC settings = map[string]string{ - providersKey: rpcAddr, + providersKey: fmt.Sprintf("[[\"%v\",\"%v\"]]", ep.addr, ep.jwt), } } seedB, _ := hex.DecodeString(seed) diff --git a/client/asset/interface.go b/client/asset/interface.go index 5c1627f864..f7ffb55860 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -250,11 +250,11 @@ type ConfigOption struct { IsDate bool `json:"isdate"` DisableWhenActive bool `json:"disablewhenactive"` IsBirthdayConfig bool `json:"isBirthdayConfig"` - // Repeatable signals a text input that can be duplicated and submitted - // multiple times, with the specified delimiter used to encode the data - // in the settings map. - Repeatable string `json:"repeatable"` - Required bool `json:"required"` + // Repeatable arguments signal a text input that can be duplicated and + // submitted multiple times. They must have the same length. + RepeatableDisplayName []string `json:"repeatableDisplayName"` + RepeatableDescription []string `json:"repeatableDescription"` + Required bool `json:"required"` // ShowByDefault to show or not options on "hide advanced options". ShowByDefault bool `json:"showByDefault,omitempty"` diff --git a/client/cmd/dexcctl/simnet-setup.sh b/client/cmd/dexcctl/simnet-setup.sh index 88bada74fe..8280f272c1 100755 --- a/client/cmd/dexcctl/simnet-setup.sh +++ b/client/cmd/dexcctl/simnet-setup.sh @@ -48,7 +48,7 @@ fi if [ $ETH_ON -eq 0 ]; then echo configuring Eth and dextt.eth wallets - ./dexcctl -p abc -p "" --simnet newwallet 60 rpc "" "{\"providers\":\"${HOME}/dextest/eth/alpha/node/geth.ipc\"}" + ./dexcctl -p abc -p "" --simnet newwallet 60 rpc "" '{"providersv1":"[[\"~/dextest/eth/alpha/node/geth.ipc\"]]"}' ./dexcctl -p abc -p "" --simnet newwallet 60000 rpc fi diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index ce438c640d..41fe5c6dc1 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -21,11 +21,13 @@ -
- -
- -
+
+
+ +
+ +
+
diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index 037aca5f43..31550d8063 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -417,6 +417,7 @@ export class WalletConfigForm { textInputTmpl: PageElement dateInputTmpl: PageElement checkboxTmpl: PageElement + repeatableTmplOuter: PageElement repeatableTmpl: PageElement fileSelector: PageElement fileInput: PageElement @@ -449,8 +450,14 @@ export class WalletConfigForm { this.dateInputTmpl.remove() this.checkboxTmpl = Doc.tmplElement(form, 'checkbox') this.checkboxTmpl.remove() - this.repeatableTmpl = Doc.tmplElement(form, 'repeatableInput') + // repeatableTmplOuter holds the repeatableInput element that we will + // duplicate. Outer also exists to be duplicated but can be used to find + // the last child per repeatableInput chain. + this.repeatableTmplOuter = Doc.tmplElement(form, 'repeatableInputOuter') + // repeatableTmpl is only ever copied and the copies are used. + this.repeatableTmpl = Doc.tmplElement(this.repeatableTmplOuter, 'repeatableInput') this.repeatableTmpl.remove() + this.repeatableTmplOuter.remove() this.fileSelector = Doc.tmplElement(form, 'fileSelector') this.fileInput = Doc.tmplElement(form, 'fileInput') this.errMsg = Doc.tmplElement(form, 'errMsg') @@ -507,18 +514,52 @@ export class WalletConfigForm { if (loadedOpts + defaultOpts === 0) Doc.hide(this.showOther, this.otherSettings) } - addOpt (box: HTMLElement, opt: ConfigOption, insertAfter?: PageElement, n?: number): PageElement { + /* + * copyRepeatableOpt copies the repeatable option that can be used for the + * ith place. + */ + copyRepeatableOpt (opt: ConfigOption, i: number): ConfigOption { + const description = opt.repeatableDescription ? opt.repeatableDescription[i] : '' + const displayname = opt.repeatableDisplayName ? opt.repeatableDisplayName[i] : '' + return { + key: opt.key, + displayname: displayname, + description: description, + repeatableDisplayName: opt.repeatableDisplayName, + repeatableDescription: opt.repeatableDescription, + required: true, + default: '' + } + } + + /* + * addOpt adds options. n and noClick are used with repeatable options. n is + * always the package level repeatableCounter. + */ + addOpt (box: HTMLElement, opt: ConfigOption, n?: number, noClick?: boolean): PageElement { const elID = 'wcfg-' + opt.key + (n ? String(n) : '') let el: HTMLElement if (opt.isboolean) el = this.checkboxTmpl.cloneNode(true) as HTMLElement else if (opt.isdate) el = this.dateInputTmpl.cloneNode(true) as HTMLElement - else if (opt.repeatable) { + else if (opt.repeatableDescription) { el = this.repeatableTmpl.cloneNode(true) as HTMLElement - el.classList.add('repeatable') - Doc.bind(Doc.tmplElement(el, 'add'), 'click', () => { - repeatableCounter++ - this.addOpt(box, opt, el, repeatableCounter) - }) + if (noClick) { + // Disable adding for all but the first repeatable option of a set. + Doc.hide(Doc.tmplElement(el, 'add')) + } else { + // Adding for repeatable options may add several elements to the end + // of box. + Doc.bind(Doc.tmplElement(el, 'add'), 'click', () => { + repeatableCounter++ + if (opt.repeatableDescription) { + for (let i = 0; i < opt.repeatableDescription.length; i++) { + const o = this.copyRepeatableOpt(opt, i) + if (i === 0) this.addOpt(box, o, repeatableCounter) + else this.addOpt(box, o, repeatableCounter, true) + } + } + }) + } } else el = this.textInputTmpl.cloneNode(true) as HTMLElement this.configElements.push([opt, el]) const input = el.querySelector('input') as ConfigOptionInput @@ -533,8 +574,7 @@ export class WalletConfigForm { label.prepend(logo) opt.regAsset = this.formOpts.assetID // Signal for map filtering } - if (insertAfter) insertAfter.after(el) - else box.appendChild(el) + box.appendChild(el) if (opt.noecho) { input.type = 'password' input.autocomplete = 'off' @@ -580,7 +620,26 @@ export class WalletConfigForm { const defaultedOpts = [] for (const opt of this.configOpts) { if (this.sectionize && opt.default !== null) defaultedOpts.push(opt) - else this.addOpt(this.dynamicOpts, opt) + else if (opt.repeatableDescription) { + // Add the repeatable template outer element back to the DOM if + // repeatables will be used. + // + // TODO: This is not correct if a repeatable option is used more than + // once in a form. More boxes will need to be created and inserted into + // the DOM. + const box = this.repeatableTmplOuter + this.dynamicOpts.appendChild(box) + for (let i = 0; i < opt.repeatableDescription.length; i++) { + const o = this.copyRepeatableOpt(opt, i) + if (i === 0) this.addOpt(box, o, repeatableCounter) + else { + repeatableCounter++ + this.addOpt(box, o, repeatableCounter, true) + } + } + } else { + this.addOpt(this.dynamicOpts, opt) + } } if (defaultedOpts.length) { for (const opt of defaultedOpts) { @@ -623,21 +682,37 @@ export class WalletConfigForm { const [opt, el] = r const v = cfg[opt.key] if (v === undefined) continue - if (opt.repeatable) { + if (opt.repeatableDescription) { + // Replace all repeatable options with new ones. + removes.push(r) if (handledRepeatables[opt.key]) { - removes.push(r) continue } handledRepeatables[opt.key] = true - const vals = v.split(opt.repeatable) - const firstVal = vals[0] - finds.push(el) - Doc.safeSelector(el, 'input').value = firstVal - for (let i = 1; i < vals.length; i++) { - repeatableCounter++ - const newEl = this.addOpt(el.parentElement as PageElement, opt, el, repeatableCounter) - Doc.safeSelector(newEl, 'input').value = vals[i] - finds.push(newEl) + // TODO: This is not correct if a repeatable option is used more than + // once in a form. More boxes will need to be created and inserted into + // the DOM. + const box = this.repeatableTmplOuter + let child = box.lastChild + while (child) { + child.remove() + child = box.lastChild + } + const numVal = opt.repeatableDescription.length + // Repeatable option values are stored as an array of array of strings. + // The outer array is the number of value sets stored while inner + // arrays have the same number of values as descriptions/display names. + // There is one new element per inner value. + const vals: string[][] = JSON.parse(v) + for (let i = 0; i < vals.length; i++) { + const valSet = vals[i] + for (let j = 0; j < numVal; j++) { + repeatableCounter++ + const o = this.copyRepeatableOpt(opt, j) + const newEl = (j === 0) ? this.addOpt(box, o, repeatableCounter) : this.addOpt(box, o, repeatableCounter, true) + if (valSet.length > j) Doc.safeSelector(newEl, 'input').value = valSet[j] + finds.push(newEl) + } } continue } @@ -647,6 +722,7 @@ export class WalletConfigForm { else if (opt.isdate) input.valueAsDate = new Date(parseInt(v) * 1000) else input.value = v } + app().bindTooltips(this.allSettings) for (const r of removes) { const i = this.configElements.indexOf(r) if (i >= 0) this.configElements.splice(i, 1) @@ -686,9 +762,39 @@ export class WalletConfigForm { if (date < minDate) date = minDate else if (date > maxDate) date = maxDate config[opt.key] = '' + date + } else if (opt.repeatableDescription) { + // Reapeatables are stored as the json string representation of an array + // of an array of strings. The outer array is the number of value sets. + // The inner array is a value set of repeatables. + const numRep = opt.repeatableDescription.length + const vals: string[][] = (config[opt.key]) ? JSON.parse(config[opt.key]) : null + if (!vals || vals.length === 0) { + if (numRep === 1 && input.value === '') continue + config[opt.key] = JSON.stringify([[input.value]]) + continue + } + const lastVals = vals[vals.length - 1] + if (lastVals.length % numRep === 0) { + // New set of values. + if (numRep === 1 && input.value === '') continue + vals.push([input.value]) + } else { + // Append to the last values and delete them all if all are empty + // arrays. + lastVals.push(input.value) + if (lastVals.length === numRep) { + let allEmpty = true + lastVals.forEach(val => { if (val !== '') allEmpty = false }) + if (allEmpty) { + if (numRep !== 1) vals.pop() + } else { + vals[vals.length - 1] = lastVals + } + } + } + config[opt.key] = JSON.stringify(vals) } else if (input.value) { - if (opt.repeatable && config[opt.key]) config[opt.key] += opt.repeatable + input.value - else config[opt.key] = input.value + config[opt.key] = input.value } } return config diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 5d9f102627..ae4da6013f 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -227,7 +227,8 @@ export interface ConfigOption { isdate: boolean disablewhenactive: boolean isBirthdayConfig: boolean - repeatable?: string + repeatableDescription?: string[] + repeatableDisplayName?: string[] noauth: boolean regAsset?: number required?: boolean diff --git a/dex/networks/eth/common.go b/dex/networks/eth/common.go index 4bb734189e..7b4b0b9f31 100644 --- a/dex/networks/eth/common.go +++ b/dex/networks/eth/common.go @@ -6,9 +6,13 @@ package eth import ( + "encoding/hex" "fmt" + "net/http" + "strings" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/node" ) // DecodeCoinID decodes the coin ID into a common.Hash. For eth, there are no @@ -27,3 +31,15 @@ func DecodeCoinID(coinID []byte) (common.Hash, error) { // SecretHashSize is the byte-length of the hash of the secret key used in // swaps. const SecretHashSize = 32 + +// JWTHTTPAuthFn returns a function that creates a signed jwt token using the +// provided secret and inserts it into the passed header. +func JWTHTTPAuthFn(jwtStr string) (func(h http.Header) error, error) { + s, err := hex.DecodeString(strings.TrimPrefix(jwtStr, "0x")) + if err != nil { + return nil, err + } + var secret [32]byte + copy(secret[:], s) + return node.NewJWTAuth(secret), nil +} diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index ff95ae0591..b1638a8170 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -72,6 +72,13 @@ ETH_ON=$? set -e +if [ $ETH_ON -eq 0 ]; then + cat > "${DCRDEX_DATA_DIR}/geth.conf" < "${NODE_DIR}/jwt.hex" < "${NODE_DIR}/eth.conf" < :", + cfgPath: tFilePath, + cfg: "addr:ws://1234\njwt=asdf", + net: dex.Simnet, + wantErr: true, + }, { + name: "no addr", + cfgPath: tFilePath, + cfg: "jwt=abcd", + net: dex.Simnet, + wantErr: true, + }, { + name: "not ipc but no jwt", + cfgPath: tFilePath, + cfg: "addr=ws://1234", + net: dex.Simnet, + wantErr: true, + }, { + name: "not ipc and bad jwt", + cfgPath: tFilePath, + cfg: "addr=ws://1234\njwt=saadbeef", + net: dex.Simnet, + wantErr: true, + }} + for _, test := range tests { + if test.cfg != "" { + err := os.WriteFile(test.cfgPath, []byte(test.cfg), 0666) + if err != nil { + t.Fatal(err) + } + } + _, err := loadConfig(test.cfgPath, test.net, tLogger) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + } +} + +func TestFindJWTHex(t *testing.T) { + tDir := t.TempDir() + tFilePath := filepath.Join(tDir, "jwt.hex") + tests := []struct { + name, jwtFileContents, jwt, wantHex string + noPerms bool + wantErr bool + }{{ + name: "ok hex", + jwt: "baadbeef", + wantHex: "baadbeef", + }, { + name: "ok file", + jwt: tFilePath, + jwtFileContents: "baadbeef\n", + wantHex: "baadbeef", + }, { + name: "not hex and no file", + jwt: tFilePath, + wantErr: true, + }, { + name: "not hex but cant read file", + jwt: tFilePath, + jwtFileContents: "baadbeef\n", + noPerms: true, + wantErr: true, + }, { + name: "not hex and bad hex in file", + jwt: tFilePath, + jwtFileContents: "saadbeef\n", + wantErr: true, + }} + for _, test := range tests { + if test.jwtFileContents != "" { + perms := fs.FileMode(0666) + if test.noPerms { + perms = 0000 + } + err := os.WriteFile(tFilePath, []byte(test.jwtFileContents), perms) + if err != nil { + t.Fatal(err) + } + } + hex, err := findJWTHex(test.jwt) + if test.jwtFileContents != "" { + os.Remove(tFilePath) + } + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + if hex != test.wantHex { + t.Fatalf("wanted jwt %q but got %q for test %q", test.wantHex, hex, test.name) + } + } +} diff --git a/server/asset/eth/rpcclient.go b/server/asset/eth/rpcclient.go index e9adbd2515..b5f8f2c704 100644 --- a/server/asset/eth/rpcclient.go +++ b/server/asset/eth/rpcclient.go @@ -9,6 +9,9 @@ import ( "context" "fmt" "math/big" + "net/http" + "net/url" + "strings" "decred.org/dcrdex/dex" dexeth "decred.org/dcrdex/dex/networks/eth" @@ -34,7 +37,7 @@ type ContextCaller interface { type rpcclient struct { net dex.Network - ipc string + ep *endpoint // ec wraps a *rpc.Client with some useful calls. ec *ethclient.Client // caller is a client for raw calls not implemented by *ethclient.Client. @@ -48,21 +51,43 @@ type rpcclient struct { tokens map[uint32]*tokener } -func newRPCClient(net dex.Network, ipc string) *rpcclient { +func newRPCClient(net dex.Network, ep *endpoint) *rpcclient { return &rpcclient{ net: net, tokens: make(map[uint32]*tokener), - ipc: ipc, + ep: ep, } } // connect connects to an ipc socket. It then wraps ethclient's client and // bundles commands in a form we can easily use. func (c *rpcclient) connect(ctx context.Context) error { - client, err := rpc.DialIPC(ctx, c.ipc) - if err != nil { - return fmt.Errorf("unable to dial rpc: %v", err) + var client *rpc.Client + var err error + addr := c.ep.addr + if strings.HasSuffix(addr, ".ipc") { + client, err = rpc.DialIPC(ctx, addr) + if err != nil { + return err + } + } else { + var wsURL *url.URL + wsURL, err = url.Parse(addr) + if err != nil { + return fmt.Errorf("Failed to parse url %q", addr) + } + wsURL.Scheme = "ws" + var authFn func(h http.Header) error + authFn, err = dexeth.JWTHTTPAuthFn(c.ep.jwt) + if err != nil { + return fmt.Errorf("unable to create auth function: %v", err) + } + client, err = rpc.DialOptions(ctx, wsURL.String(), rpc.WithHTTPAuth(authFn)) + if err != nil { + return err + } } + c.ec = ethclient.NewClient(client) netAddrs, found := dexeth.ContractAddresses[ethContractVersion] diff --git a/server/asset/eth/rpcclient_harness_test.go b/server/asset/eth/rpcclient_harness_test.go index ad800e939d..e3cb0564b5 100644 --- a/server/asset/eth/rpcclient_harness_test.go +++ b/server/asset/eth/rpcclient_harness_test.go @@ -40,7 +40,7 @@ func TestMain(m *testing.M) { run := func() (int, error) { var cancel context.CancelFunc ctx, cancel = context.WithCancel(context.Background()) - ethClient = newRPCClient(dex.Simnet, ipc) + ethClient = newRPCClient(dex.Simnet, &endpoint{addr: ipc}) defer func() { cancel() ethClient.shutdown() @@ -51,7 +51,6 @@ func TestMain(m *testing.M) { netToken := dexeth.Tokens[testTokenID].NetTokens[dex.Simnet] netToken.Address = getContractAddrFromFile(tokenErc20AddrFile) netToken.SwapContracts[0].Address = getContractAddrFromFile(tokenSwapAddrFile) - registerToken(testTokenID, 0) if err := ethClient.connect(ctx); err != nil { return 1, fmt.Errorf("Connect error: %w", err) @@ -152,8 +151,9 @@ func testAccountBalance(t *testing.T, assetID uint32) { t.Fatalf("accountBalance error: %v", err) } + // NOTE: Token transfers use eth for the fee, so token difference is exact. diff := new(big.Int).Sub(balBefore, balAfter) - if diff.Cmp(dexeth.GweiToWei(vGwei)) <= 0 { + if diff.Cmp(dexeth.GweiToWei(vGwei)) < 0 { t.Fatalf("account balance changed by %d. expected > %d", dexeth.WeiToGwei(diff), uint64(vGwei)) } }