diff --git a/packages/0xsequence/tests/browser/mock-wallet/mock-wallet.test.ts b/packages/0xsequence/tests/browser/mock-wallet/mock-wallet.test.ts index 2030cc267c..a4c9dd3fb9 100644 --- a/packages/0xsequence/tests/browser/mock-wallet/mock-wallet.test.ts +++ b/packages/0xsequence/tests/browser/mock-wallet/mock-wallet.test.ts @@ -77,22 +77,13 @@ const main = async () => { context: deployedWalletContext }, owner) - - // const txn = await relayer.deployWallet(wallet.config, sequenceContext) - // console.log('...', txn) - - // the json-rpc signer via the wallet - // const mockUserPrompter = new MockWalletUserPrompter(true) const walletRequestHandler = new WalletRequestHandler(account, null, networks) - // setup and register window message transport const windowHandler = new WindowMessageHandler(walletRequestHandler) windowHandler.register() - // TODO: register the ProxyMessageHandler() + register() - } main() diff --git a/packages/0xsequence/tests/browser/mux-transport/mux.test.ts b/packages/0xsequence/tests/browser/mux-transport/mux.test.ts new file mode 100644 index 0000000000..b7339c2645 --- /dev/null +++ b/packages/0xsequence/tests/browser/mux-transport/mux.test.ts @@ -0,0 +1,154 @@ +import { + ProxyMessageProvider, ProviderMessageTransport, ProviderMessage, WalletRequestHandler, + ProxyMessageChannel, ProxyMessageHandler, Wallet, DefaultProviderConfig, Web3Provider, + WindowMessageHandler +} from '@0xsequence/provider' +import { ethers, Wallet as EOAWallet } from 'ethers' +import { JsonRpcProvider } from '@ethersproject/providers' +import { test, assert } from '../../utils/assert' +import { Networks, WalletContext } from '@0xsequence/network' +import { Wallet as SequenceWallet, Account as SequenceAccount, isValidSignature, packMessageData, recoverConfig } from '@0xsequence/wallet' +import { addressOf } from '@0xsequence/config' +import { LocalRelayer } from '@0xsequence/relayer' +import { testAccounts, getEOAWallet, testWalletContext } from '../testutils' + + +// Tests simulates a multi-message provider environment by having a wallet available via the +// proxy channel and wallet window. +export const tests = async () => { + + // + // Providers + // + const provider1 = new JsonRpcProvider('http://localhost:8545') + const provider2 = new JsonRpcProvider('http://localhost:9545') + + + // + // Deploy Sequence WalletContext (deterministic). We skip deployment + // as we rely on mock-wallet to deploy it. + // + const deployedWalletContext = testWalletContext + console.log('walletContext:', deployedWalletContext) + + + // + // Proxy Channel (normally would be out-of-band) + // + const ch = new ProxyMessageChannel() + + + // + // Wallet Handler (local mock wallet, same a mock-wallet tests) + // + + // owner account address: 0x4e37E14f5d5AAC4DF1151C6E8DF78B7541680853 + const owner = getEOAWallet(testAccounts[0].privateKey) + + + // relayers, account address: 0x3631d4d374c3710c3456d6b1de1ee8745fbff8ba + // const relayerAccount = getEOAWallet(testAccounts[5].privateKey) + const relayer1 = new LocalRelayer(getEOAWallet(testAccounts[5].privateKey)) + const relayer2 = new LocalRelayer(getEOAWallet(testAccounts[5].privateKey, provider2)) + + + // wallet account address: 0x24E78922FE5eCD765101276A422B8431d7151259 based on the chainId + const swallet = (await SequenceWallet.singleOwner(owner, deployedWalletContext)).connect(provider1, relayer1) + + // Network available list + const networks: Networks = [ + { + name: 'hardhat', + chainId: 31337, + rpcUrl: provider1.connection.url, + provider: provider1, + relayer: relayer1, + isDefaultChain: true, + // isAuthChain: true + }, + { + name: 'hardhat2', + chainId: 31338, + rpcUrl: provider2.connection.url, + provider: provider2, + relayer: relayer2, + isAuthChain: true + } + ] + + // Account for managing multi-network wallets + const saccount = new SequenceAccount({ + initialConfig: swallet.config, + networks, + context: deployedWalletContext + }, owner) + + // the rpc signer via the wallet + const walletRequestHandler = new WalletRequestHandler(saccount, null, networks) + + // register wallet message handler, in this case using the ProxyMessage transport. + const proxyHandler = new ProxyMessageHandler(walletRequestHandler, ch.wallet) + proxyHandler.register() + + // register window message transport + const windowHandler = new WindowMessageHandler(walletRequestHandler) + windowHandler.register() + + + // + // Dapp, wallet provider and dapp tests + // + + // wallet provider with multiple message provider transports enabled + const wallet = new Wallet('hardhat', { + walletAppURL: 'http://localhost:9999/mock-wallet/mock-wallet.test.html', + transports: { + windowTransport: { enabled: true }, + proxyTransport: { enabled: true, appPort: ch.app } + } + }) + + // provider + signer, by default if a chainId is not specified it will direct + // requests to the defaultChain + // const provider = wallet.getProvider() + // const signer = wallet.getSigner() + + // clear it in case we're testing in browser session + wallet.logout() + + await test('is logged out', async () => { + assert.false(wallet.isLoggedIn(), 'is logged out') + }) + + await test('is disconnected', async () => { + assert.false(wallet.isConnected(), 'is disconnnected') + }) + + await test('login', async () => { + const loggedIn = await wallet.login() + assert.true(loggedIn, 'is logged in') + }) + + await test('isConnected', async () => { + assert.true(wallet.isConnected(), 'is connected') + }) + + let walletContext: WalletContext + await test('getWalletContext', async () => { + walletContext = await wallet.getWalletContext() + assert.equal(walletContext.factory, deployedWalletContext.factory, 'wallet context factory') + assert.equal(walletContext.guestModule, deployedWalletContext.guestModule, 'wallet context guestModule') + }) + + await test('getChainId', async () => { + const chainId = await wallet.getChainId() + assert.equal(chainId, 31337, 'chainId is correct') + }) + + await test('getChainId for other chain', async () => { + const p = wallet.getProvider(31338) + assert.equal(await p.getChainId(), 31338, 'chainId of other chain is 31338') + }) + +} + \ No newline at end of file diff --git a/packages/0xsequence/tests/browser/proxy-transport/channel.test.ts b/packages/0xsequence/tests/browser/proxy-transport/channel.test.ts index 2e8c3c821b..713b2fe5b3 100644 --- a/packages/0xsequence/tests/browser/proxy-transport/channel.test.ts +++ b/packages/0xsequence/tests/browser/proxy-transport/channel.test.ts @@ -1,6 +1,6 @@ -import { ProxyMessageProvider, ProviderMessageTransport, ProviderMessage, WalletRequestHandler, ProxyMessageChannel, ProxyMessageHandler } from '@0xsequence/provider' +import { Web3Provider, ProxyMessageProvider, ProviderMessageTransport, ProviderMessage, WalletRequestHandler, ProxyMessageChannel, ProxyMessageHandler } from '@0xsequence/provider' import { ethers, Wallet as EOAWallet } from 'ethers' -import { Web3Provider, JsonRpcProvider } from '@ethersproject/providers' +import { JsonRpcProvider } from '@ethersproject/providers' import { test, assert } from '../../utils/assert' import { sequenceContext, testnetNetworks } from '@0xsequence/network' import { Wallet, isValidSignature, packMessageData, recoverConfig } from '@0xsequence/wallet' @@ -17,13 +17,15 @@ export const tests = async () => { // `ch.app` (port) will be injected into the app, and `ch.wallet` (port) will be injected into the wallet. // // Sending messages to the app port will go through channel and get received by the wallet. - // Sending message to the wallet port will go through channel and get received by the app. + // Sending messages to the wallet port will go through channel and get received by the app. const ch = new ProxyMessageChannel() - // - // App Provider - // - const walletProvider = new ProxyMessageProvider(ch.app) + ch.app.on('connect', () => { + console.log('wallet connected.') + }) + ch.app.on('disconnect', () => { + console.log('wallet disconnected.') + }) // // Wallet Handler @@ -39,24 +41,30 @@ export const tests = async () => { const rpcProvider = new JsonRpcProvider('http://localhost:8545') const wallet = (await Wallet.singleOwner(owner)).connect(rpcProvider, relayer) - - // the rpc signer via the wallet const walletRequestHandler = new WalletRequestHandler(wallet, null, []) + // register wallet message handler, in this case using the ProxyMessage transport. const proxyHandler = new ProxyMessageHandler(walletRequestHandler, ch.wallet) proxyHandler.register() - //-- - // TODO: switch to Sequence Web3Provider ........ + // + // App Provider + // + const walletProvider = new ProxyMessageProvider(ch.app) + walletProvider.register() + + walletProvider.openWallet() + await walletProvider.waitUntilConnected() + + // setup web3 provider const provider = new Web3Provider(walletProvider) const signer = provider.getSigner() - const address = await signer.getAddress() await test('verifying getAddress result', async () => { - assert.equal(address, '0x24E78922FE5eCD765101276A422B8431d7151259', 'wallet address') + assert.equal(address.toLowerCase(), '0x24E78922FE5eCD765101276A422B8431d7151259'.toLowerCase(), 'wallet address') }) await test('sending a json-rpc request', async () => { @@ -81,9 +89,6 @@ export const tests = async () => { await test('sign a message and validate/recover', async () => { const message = ethers.utils.toUtf8Bytes('hihi') - // TODO: signer should be a Sequence signer, and should be able to specify the chainId - // however, for a single wallet, it can check the chainId and throw if doesnt match, for multi-wallet it will select - // // Sign the message // @@ -121,7 +126,6 @@ export const tests = async () => { assert.true(singleSignerAddress.toLowerCase() === walletConfig.signers[0].address.toLowerCase(), 'owner address check') }) - // TODO: we need to test wallet notifications from wallet to app.. - // TODO: perhaps we can trigger a network change there..? and notifyNetwork..? + walletProvider.closeWallet() } diff --git a/packages/0xsequence/tests/browser/wallet-provider/dapp.test.ts b/packages/0xsequence/tests/browser/wallet-provider/dapp.test.ts index f90dae671d..dcf8d30015 100644 --- a/packages/0xsequence/tests/browser/wallet-provider/dapp.test.ts +++ b/packages/0xsequence/tests/browser/wallet-provider/dapp.test.ts @@ -114,8 +114,6 @@ export const tests = async () => { assert.equal(allWalletStates.length, 2, '2 wallet states (one for each chain)') // we expect network order to be [defaultChain, authChain, ..], so chain 31337 will be at index 0 - // hmm.. TODO: WalletProvider defaultNetwork should specify the "defaultNetwork", which will - // become our *defaultChain* .. const state1 = allWalletStates[0] assert.true(state1.chainId === 31337, 'state1, chainId is 31337') assert.true(state1.config.threshold === 1, 'state1, threshold') diff --git a/packages/0xsequence/tests/mux-transport.spec.ts b/packages/0xsequence/tests/mux-transport.spec.ts new file mode 100644 index 0000000000..814f019ec3 --- /dev/null +++ b/packages/0xsequence/tests/mux-transport.spec.ts @@ -0,0 +1,3 @@ +import { runBrowserTests } from './utils/browser-test-runner' + +runBrowserTests('mux-transport', 'mux-transport/mux.test.html') diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 52f30aa402..ca7d4a2481 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -25,7 +25,7 @@ export interface WalletState { deployed: boolean imageHash: string - currentImageHash?: string // TODO: rename to deployedImageHash + currentImageHash?: string published?: boolean } diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index 8dcb87c3de..ca4bd64cf2 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -1,5 +1,4 @@ export * from './transports' export * from './types' export * from './wallet' -export * from './wallet-request-handler' export * from './provider' diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index c573218ed2..a667707a69 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -8,7 +8,7 @@ import { WalletConfig, WalletState } from '@0xsequence/config' import { Relayer } from '@0xsequence/relayer' import { Deferrable, shallowCopy, resolveProperties } from '@0xsequence/utils' import { TransactionRequest, TransactionResponse, Transactionish, SignedTransactions } from '@0xsequence/transactions' -import { WalletRequestHandler } from './wallet-request-handler' +import { WalletRequestHandler } from './transports/wallet-request-handler' // naming..? // Web3Provider, Web3Signer, Web3Relayer, Web3Indexer diff --git a/packages/provider/src/transports/base-provider-transport.ts b/packages/provider/src/transports/base-provider-transport.ts index 370062330d..9d9658ff83 100644 --- a/packages/provider/src/transports/base-provider-transport.ts +++ b/packages/provider/src/transports/base-provider-transport.ts @@ -4,7 +4,7 @@ import { ProviderTransport, ProviderMessage, ProviderMessageRequest, ProviderMessageType, ProviderMessageEvent, ProviderMessageResponse, ProviderMessageResponseCallback, ProviderMessageTransport, - WalletSession + WalletSession, ConnectionState } from '../types' import { NetworkConfig, JsonRpcRequest, JsonRpcResponseCallback, JsonRpcResponse } from '@0xsequence/network' @@ -20,7 +20,7 @@ export abstract class BaseProviderTransport implements ProviderTransport { protected pendingMessageRequests: ProviderMessageRequest[] = [] protected responseCallbacks = new Map() - protected connected = false + protected connection: ConnectionState protected sessionId: string protected confirmationOnly: boolean = false protected events: EventEmitter = new EventEmitter() @@ -28,9 +28,22 @@ export abstract class BaseProviderTransport implements ProviderTransport { protected accountPayload: string protected networksPayload: NetworkConfig[] - constructor() {} + protected registered: boolean - openWallet = (path?: string, state?: any): void => { + constructor() { + this.connection = ConnectionState.DISCONNECTED + this.registered = false + } + + register() { + throw new Error('abstract method') + } + + unregister() { + throw new Error('abstract method') + } + + openWallet(path?: string, state?: any) { throw new Error('abstract method') } @@ -39,11 +52,39 @@ export abstract class BaseProviderTransport implements ProviderTransport { } isConnected(): boolean { - return this.connected + return this.registered && this.connection === ConnectionState.CONNECTED } - sendAsync = async (request: JsonRpcRequest, callback: JsonRpcResponseCallback, chainId?: number): Promise => { - throw new Error('abstract method') + sendAsync = async (request: JsonRpcRequest, callback: JsonRpcResponseCallback, chainId?: number) => { + // here, we receive the message from the dapp provider call + + if (this.connection === ConnectionState.DISCONNECTED ) { + // flag the wallet to auto-close once user submits input. ie. + // prompting to sign a message or transaction + this.confirmationOnly = true + } + + // open/focus the wallet. + // automatically open the wallet when a provider request makes it here. + await this.openWallet() + + // double check, in case wallet failed to open + if (!this.isConnected()) { + throw new Error('wallet is not connected.') + } + + // send message request, await, and then execute callback after receiving the response + try { + const response = await this.sendMessageRequest({ + idx: nextMessageIdx(), + type: ProviderMessageType.MESSAGE, + data: request, + chainId: chainId + }) + callback(undefined, response.data) + } catch (err) { + callback(err) + } } // handleMessage will handle message received from the remote wallet @@ -61,13 +102,13 @@ export abstract class BaseProviderTransport implements ProviderTransport { // CONNECT response // // Flip connected flag, and flush the pending queue - if (message.type === ProviderMessageType.CONNECT && !this.connected) { + if (message.type === ProviderMessageType.CONNECT && !this.isConnected()) { if (this.sessionId !== message.data?.result?.sessionId) { console.log('connect received from wallet, but does not match id', this.sessionId) return } - this.connected = true + this.connection = ConnectionState.CONNECTED this.events.emit('connect') // flush pending requests when connected @@ -156,13 +197,12 @@ export abstract class BaseProviderTransport implements ProviderTransport { reject(new Error('duplicate message idx, should never happen')) } - if (!this.connected) { + if (!this.isConnected()) { console.log('pushing to pending requests', message) this.pendingMessageRequests.push(message) - return + } else { + this.sendMessage(message) } - - this.sendMessage(message) }) } @@ -170,11 +210,11 @@ export abstract class BaseProviderTransport implements ProviderTransport { throw new Error('abstract method') } - on = (event: ProviderMessageEvent, fn: (...args: any[]) => void) => { + on(event: ProviderMessageEvent, fn: (...args: any[]) => void) { this.events.on(event, fn) } - once = (event: ProviderMessageEvent, fn: (...args: any[]) => void) => { + once(event: ProviderMessageEvent, fn: (...args: any[]) => void) { this.events.once(event, fn) } @@ -248,7 +288,42 @@ export abstract class BaseProviderTransport implements ProviderTransport { ]) } + protected connect = async (): Promise => { + if (this.isConnected()) return true + + // Send connection request and wait for confirmation + this.connection = ConnectionState.CONNECTING + + // CONNECT is special case, as we emit multiple tranmissions waiting for a response + const initRequest: ProviderMessage = { + idx: nextMessageIdx(), + type: ProviderMessageType.CONNECT, + data: null + } + + // Continually send connect requesst until we're connected or timeout passes + let connected: boolean = undefined + const postMessageUntilConnected = () => { + if (!this.registered) return false + if (connected !== undefined) return connected + + this.sendMessage(initRequest) + setTimeout(postMessageUntilConnected, 200) + } + postMessageUntilConnected() + + // Wait for connection or timeout + try { + connected = await this.waitUntilConnected() + } catch (err) { + connected = false + } + return connected + } + protected disconnect() { + this.connection = ConnectionState.DISCONNECTED + this.confirmationOnly = false console.log('disconnecting wallet and flushing!') // flush pending requests and return error to all callbacks @@ -260,5 +335,7 @@ export abstract class BaseProviderTransport implements ProviderTransport { this.accountPayload = undefined this.networksPayload = undefined + + this.events.emit('disconnect') } } diff --git a/packages/provider/src/transports/base-wallet-transport.ts b/packages/provider/src/transports/base-wallet-transport.ts index e0677ba129..bc1fcde353 100644 --- a/packages/provider/src/transports/base-wallet-transport.ts +++ b/packages/provider/src/transports/base-wallet-transport.ts @@ -4,7 +4,7 @@ import { ProviderMessageType, ProviderMessageResponse, ProviderMessageTransport } from '../types' -import { WalletRequestHandler } from '../wallet-request-handler' +import { WalletRequestHandler } from './wallet-request-handler' import { NetworkConfig, JsonRpcRequest, JsonRpcResponseCallback } from '@0xsequence/network' diff --git a/packages/provider/src/transports/index.ts b/packages/provider/src/transports/index.ts index 1848711af3..7b635d9ac5 100644 --- a/packages/provider/src/transports/index.ts +++ b/packages/provider/src/transports/index.ts @@ -1,4 +1,6 @@ export * from './base-provider-transport' export * from './base-wallet-transport' export * from './proxy-transport' +export * from './mux-transport' export * from './window-transport' +export * from './wallet-request-handler' diff --git a/packages/provider/src/transports/mux-transport/index.ts b/packages/provider/src/transports/mux-transport/index.ts new file mode 100644 index 0000000000..6a69b9e8d0 --- /dev/null +++ b/packages/provider/src/transports/mux-transport/index.ts @@ -0,0 +1 @@ +export * from './mux-message-provider' diff --git a/packages/provider/src/transports/mux-transport/mux-message-provider.ts b/packages/provider/src/transports/mux-transport/mux-message-provider.ts new file mode 100644 index 0000000000..7d2f67462f --- /dev/null +++ b/packages/provider/src/transports/mux-transport/mux-message-provider.ts @@ -0,0 +1,151 @@ +import { BaseProviderTransport, nextMessageIdx } from '../base-provider-transport' +import { + ProviderMessage, ProviderMessageResponseCallback, ProviderMessageType, ProviderTransport, + ProviderMessageEvent, ProviderMessageRequest, ProviderMessageResponse, WalletSession +} from '../../types' + +import { JsonRpcRequest, JsonRpcResponseCallback } from '@0xsequence/network' + +export class MuxMessageProvider implements ProviderTransport { + + private messageProviders: ProviderTransport[] + private provider: ProviderTransport + + constructor(...messageProviders: ProviderTransport[]) { + this.messageProviders = messageProviders + this.provider = undefined + } + + add(...messageProviders: ProviderTransport[]) { + this.messageProviders.push(...messageProviders) + } + + register = () => { + if (this.messageProviders.length === 1) { + this.provider = this.messageProviders[0] + this.provider.register() + return + } + + this.messageProviders.forEach(m => { + m.register() + + m.once('connect', () => { + // the first one to connect is the winner, and others will be unregistered + if (!this.provider) { + this.provider = m + + // unregister other providers + this.messageProviders.forEach(m => { + if (this.provider !== m) { + m.unregister() + } + }) + } + }) + }) + } + + unregister = () => { + this.messageProviders.forEach(m => m.unregister()) + this.provider = undefined + } + + openWallet = (path?: string, state?: any): void => { + if (this.provider) { + this.provider.openWallet(path, state) + return + } + this.messageProviders.forEach(m => m.openWallet(path, state)) + } + + closeWallet() { + if (this.provider) { + this.provider.closeWallet() + } + } + + isConnected(): boolean { + if (this.provider) { + return this.provider.isConnected() + } + return false + } + + on(event: ProviderMessageEvent, fn: (...args: any[]) => void) { + if (this.provider) { + this.provider.on(event, fn) + return + } + this.messageProviders.forEach(m => { + m.on(event, fn) + }) + } + + once(event: ProviderMessageEvent, fn: (...args: any[]) => void) { + if (this.provider) { + this.provider.once(event, fn) + return + } + this.messageProviders.forEach(m => { + m.once(event, fn) + }) + } + + sendAsync = async (request: JsonRpcRequest, callback: JsonRpcResponseCallback, chainId?: number) => { + if (this.provider) { + this.provider.sendAsync(request, callback, chainId) + return + } + throw new Error('impossible state, must be connected first') + } + + sendMessage(message: ProviderMessage) { + if (!message.idx || message.idx <= 0) { + throw new Error('message idx is empty') + } + + // connected + if (this.provider) { + this.provider.sendMessage(message) + return + } + + // not connceted + if (message.type === ProviderMessageType.CONNECT) { + this.messageProviders.forEach(m => m.sendMessage(message)) + } else { + throw new Error('impossible state, must be connected first') + } + } + + sendMessageRequest = async (message: ProviderMessageRequest): Promise => { + if (this.provider) { + return this.provider.sendMessageRequest(message) + } + throw new Error('impossible state, must be connected first') + } + + handleMessage(message: ProviderMessage): void { + if (this.provider) { + this.provider.handleMessage(message) + return + } + throw new Error('impossible state, must be connected first') + } + + waitUntilConnected = async (): Promise => { + if (this.provider) { + return this.provider.waitUntilConnected() + } + throw new Error('impossible state, must be connected first') + } + + waitUntilLoggedIn = async (): Promise => { + if (this.provider) { + return this.provider.waitUntilLoggedIn() + } + throw new Error('impossible state, must be connected first') + } + +} diff --git a/packages/provider/src/transports/proxy-transport/proxy-message-channel.ts b/packages/provider/src/transports/proxy-transport/proxy-message-channel.ts index 306ccf61c7..8f54a50a78 100644 --- a/packages/provider/src/transports/proxy-transport/proxy-message-channel.ts +++ b/packages/provider/src/transports/proxy-transport/proxy-message-channel.ts @@ -1,4 +1,5 @@ -import { ProviderMessage, ProviderMessageTransport } from '../../types' +import EventEmitter from 'eventemitter3' +import { ProviderMessage, ProviderMessageTransport, ProviderMessageEvent } from '../../types' export class ProxyMessageChannel { app: ProxyMessageChannelPort @@ -18,6 +19,7 @@ export class ProxyMessageChannel { export class ProxyMessageChannelPort implements ProviderMessageTransport { conn: ProviderMessageTransport + events: EventEmitter = new EventEmitter() handleMessage = (message: ProviderMessage): void => { throw new Error('ProxyMessageChannelPort is not registered') @@ -26,4 +28,14 @@ export class ProxyMessageChannelPort implements ProviderMessageTransport { sendMessage = (message: ProviderMessage): void => { this.conn.handleMessage(message) } + + on(event: ProxyMessageEvent, fn: (...args: any[]) => void) { + this.events.on(event, fn) + } + + once(event: ProxyMessageEvent, fn: (...args: any[]) => void) { + this.events.once(event, fn) + } } + +type ProxyMessageEvent = 'connect' | 'disconnect' diff --git a/packages/provider/src/transports/proxy-transport/proxy-message-handler.ts b/packages/provider/src/transports/proxy-transport/proxy-message-handler.ts index 5955fec2b9..8c5db0af85 100644 --- a/packages/provider/src/transports/proxy-transport/proxy-message-handler.ts +++ b/packages/provider/src/transports/proxy-transport/proxy-message-handler.ts @@ -1,5 +1,5 @@ import { BaseWalletTransport } from '../base-wallet-transport' -import { WalletRequestHandler } from '../../wallet-request-handler' +import { WalletRequestHandler } from '../wallet-request-handler' import { ProviderMessage } from '../../types' import { ProxyMessageChannelPort } from './proxy-message-channel' @@ -18,6 +18,10 @@ export class ProxyMessageHandler extends BaseWalletTransport { } } + unregister() { + this.port.handleMessage = undefined + } + sendMessage(message: ProviderMessage) { this.port.sendMessage(message) } diff --git a/packages/provider/src/transports/proxy-transport/proxy-message-provider.ts b/packages/provider/src/transports/proxy-transport/proxy-message-provider.ts index 39613465fe..b2dd136c38 100644 --- a/packages/provider/src/transports/proxy-transport/proxy-message-provider.ts +++ b/packages/provider/src/transports/proxy-transport/proxy-message-provider.ts @@ -3,11 +3,9 @@ import { BaseProviderTransport, nextMessageIdx } from '../base-provider-transpor import { ProviderMessageResponse, ProviderMessage, ProviderMessageResponseCallback, ProviderMessageType, - ProviderMessageRequest, ProviderMessageTransport + ProviderMessageRequest, ProviderMessageTransport, ConnectionState } from '../../types' -import { JsonRpcHandler, JsonRpcRequest, JsonRpcResponseCallback, JsonRpcResponse } from '@0xsequence/network' - import { ProxyMessageChannelPort } from './proxy-message-channel' export class ProxyMessageProvider extends BaseProviderTransport { @@ -16,35 +14,41 @@ export class ProxyMessageProvider extends BaseProviderTransport { constructor(port: ProxyMessageChannelPort) { super() - - this.connected = true // assume always connected - + this.connection = ConnectionState.DISCONNECTED this.port = port + if (!port) { + throw new Error('port argument cannot be empty') + } + } + + register = () => { this.port.handleMessage = (message: ProviderMessage): void => { this.handleMessage(message) } + + this.on('connect', (...args: any[]) => { + this.port.events.emit('connect', args) + }) + this.on('disconnect', (...args: any[]) => { + this.port.events.emit('disconnect', args) + }) + + this.registered = true } - // TODO: add register() method. + unregister = () => { + this.registered = false + this.closeWallet() + this.events.removeAllListeners() + this.port.handleMessage = undefined + } openWallet = (path?: string, state?: any): void => { - // assume the wallet is already opened or handled by another process - return + this.connect() } closeWallet() { - // closing the wallet is handled by another process - return - } - - sendAsync = async (request: JsonRpcRequest, callback: JsonRpcResponseCallback, chainId?: number) => { - const response = await this.sendMessageRequest({ - idx: nextMessageIdx(), - type: ProviderMessageType.MESSAGE, - data: request, - chainId: chainId - }) - callback(undefined, response.data) + this.disconnect() } sendMessage(message: ProviderMessage) { diff --git a/packages/provider/src/wallet-request-handler.ts b/packages/provider/src/transports/wallet-request-handler.ts similarity index 99% rename from packages/provider/src/wallet-request-handler.ts rename to packages/provider/src/transports/wallet-request-handler.ts index 14e889db96..3a07f742a4 100644 --- a/packages/provider/src/wallet-request-handler.ts +++ b/packages/provider/src/transports/wallet-request-handler.ts @@ -5,7 +5,7 @@ import { WalletMessageEvent, ProviderMessageResponseCallback, ProviderMessageRequestHandler, MessageToSign -} from './types' +} from '../types' import { BigNumber, ethers } from 'ethers' import { JsonRpcProvider, ExternalProvider } from '@ethersproject/providers' diff --git a/packages/provider/src/transports/window-transport/window-message-handler.ts b/packages/provider/src/transports/window-transport/window-message-handler.ts index 8c33047f32..502b62558b 100644 --- a/packages/provider/src/transports/window-transport/window-message-handler.ts +++ b/packages/provider/src/transports/window-transport/window-message-handler.ts @@ -1,5 +1,5 @@ import { ProviderMessageRequest, ProviderMessage, ProviderMessageType, ProviderMessageResponse } from '../../types' -import { WalletRequestHandler } from '../../wallet-request-handler' +import { WalletRequestHandler } from '../wallet-request-handler' import { BaseWalletTransport } from '../base-wallet-transport' import { sanitizeNumberString } from '@0xsequence/utils' diff --git a/packages/provider/src/transports/window-transport/window-message-provider.ts b/packages/provider/src/transports/window-transport/window-message-provider.ts index cf35a9ed22..c3c0e818c6 100644 --- a/packages/provider/src/transports/window-transport/window-message-provider.ts +++ b/packages/provider/src/transports/window-transport/window-message-provider.ts @@ -1,11 +1,5 @@ -import { - ProviderMessageResponse, - ProviderMessage, ProviderMessageResponseCallback, ProviderMessageType, - ProviderMessageRequest -} from '../../types' -import { BaseProviderTransport, nextMessageIdx, PROVIDER_CONNECT_TIMEOUT } from '../base-provider-transport' - -import { JsonRpcHandler, JsonRpcRequest, JsonRpcResponseCallback, JsonRpcResponse } from '@0xsequence/network' +import { ProviderMessage } from '../../types' +import { BaseProviderTransport } from '../base-provider-transport' // .. let registeredWindowMessageProvider: WindowMessageProvider @@ -13,7 +7,6 @@ let registeredWindowMessageProvider: WindowMessageProvider export class WindowMessageProvider extends BaseProviderTransport { private walletURL: URL private walletWindow: Window - private walletOpened: boolean constructor(walletAppURL: string) { super() @@ -30,10 +23,20 @@ export class WindowMessageProvider extends BaseProviderTransport { registeredWindowMessageProvider = this // disconnect clean up - this.on('disconnect', () => this.disconnect()) + this.on('disconnect', () => { + if (this.walletWindow) { + this.walletWindow.close() + this.walletWindow = undefined + } + }) + + this.registered = true } unregister = () => { + this.registered = false + this.closeWallet() + // disable message listener if (registeredWindowMessageProvider === this) { registeredWindowMessageProvider = undefined @@ -45,16 +48,10 @@ export class WindowMessageProvider extends BaseProviderTransport { } openWallet = (path?: string, state?: any): void => { - if (this.walletOpened === true) { - if (!path) { - this.walletWindow.focus() - return - } else { - // URL was changed, closing wallet to open at proper URL - // TODO: Should be able to just push to new URL without having to re-open - this.walletWindow.close() - this.walletWindow = null - } + if (this.walletWindow && this.isConnected()) { + // TODO: update the location of window to path + this.walletWindow.focus() + return } this.sessionId = `${performance.now()}` @@ -80,105 +77,44 @@ export class WindowMessageProvider extends BaseProviderTransport { const popup = window.open(walletURL.href, 'sequenceWalletApp', windowFeatures) // Popup blocking detection and notice - let warned = false - const warnPopupBlocked = () => { - if (warned) return - warned = true - // alert('popup is blocked! hey yo') // NOTE: for debug purposes only - throw new Error('popup is blocked') - } - - const popupCheck = setTimeout(() => { - if (!popup || popup.closed || typeof popup.closed === 'undefined') { - // popup is definitely blocked if we reach here. - warnPopupBlocked() - } - }, 1000) - - const popupBlocked = popup === null || popup === undefined - if (popupBlocked) { - warnPopupBlocked() - return - } + // let warned = false + // const warnPopupBlocked = () => { + // if (warned) return + // warned = true + // // alert('popup is blocked! hey yo') // NOTE: for debug purposes only + // throw new Error('popup is blocked') + // } + + // const popupCheck = setTimeout(() => { + // if (!popup || popup.closed || typeof popup.closed === 'undefined') { + // // popup is definitely blocked if we reach here. + // warnPopupBlocked() + // } + // }, 1000) + + // const popupBlocked = popup === null || popup === undefined + // if (popupBlocked) { + // warnPopupBlocked() + // return + // } // Popup window is available this.walletWindow = popup - this.walletOpened = true - - // Send connection request and wait for confirmation - if (!this.connected) { - - // CONNECT is special case, as we emit multiple tranmissions waiting for a response - const initRequest: ProviderMessage = { - idx: nextMessageIdx(), - type: ProviderMessageType.CONNECT, - data: null - } - const postMessageUntilConnected = () => { - if (this.connected || warned) { - clearTimeout(popupCheck) - return - } - this.sendMessage(initRequest) - setTimeout(postMessageUntilConnected, 250) - } - postMessageUntilConnected() - } - - // Heartbeat to track if wallet is closed / disconnected + // Heartbeat to track if window closed const interval = setInterval(() => { if (popup && popup.closed) { clearInterval(interval) - this.walletOpened = false - this.connected = false - this.events.emit('disconnect') + this.disconnect() } }, 1250) - } - closeWallet() { - this.confirmationOnly = false - if (this.walletWindow) { - this.walletWindow.close() - this.walletWindow = null - } + // connect to the wallet by sending requests + this.connect() } - sendAsync = async (request: JsonRpcRequest, callback: JsonRpcResponseCallback, chainId?: number) => { - // here, we receive the message from the dapp provider call - - // automatically open the wallet when a provider request makes it here - if (!this.walletOpened) { - // toggle the wallet to auto-close once user submits input. ie. - // prompting to sign a message or transaction - this.confirmationOnly = true - - // open the wallet - await this.openWallet() - } else { - // TODO: we could add focusWallet() method I guess..? - // and then we could move this to the base provider .. - await this.walletWindow.focus() - } - - // double check, in case wallet failed to open - if (!this.walletOpened) { - throw new Error('wallet is not opened.') - } - - // send message request, await, and then execute callback after receiving the response - try { - const response = await this.sendMessageRequest({ - idx: nextMessageIdx(), - type: ProviderMessageType.MESSAGE, - data: request, - chainId: chainId - }) - callback(undefined, response.data) - } catch (err) { - callback(err) - } + closeWallet() { + this.disconnect() } // onmessage, receives ProviderMessageResponse from the wallet post-message transport @@ -208,6 +144,10 @@ export class WindowMessageProvider extends BaseProviderTransport { if (!message.idx || message.idx <= 0) { throw new Error('message idx is empty') } + if (!this.walletWindow) { + console.warn('WindowMessageProvider: sendMessage failed as walletWindow is unavailable') + return + } const postedMessage = typeof message !== 'string' ? JSON.stringify(message) : message this.walletWindow.postMessage(postedMessage, this.walletURL.origin) } diff --git a/packages/provider/src/types.ts b/packages/provider/src/types.ts index 2141409199..3a83eacac6 100644 --- a/packages/provider/src/types.ts +++ b/packages/provider/src/types.ts @@ -15,7 +15,9 @@ export interface WalletSession { } export interface ProviderTransport extends JsonRpcHandler, ProviderMessageTransport, ProviderMessageRequestHandler { - openWallet(path?: string, state?: any): void + register() + unregister() + openWallet(path?: string, state?: any) closeWallet() isConnected(): boolean on(event: ProviderMessageEvent, fn: (...args: any[]) => void) @@ -61,7 +63,7 @@ export interface ProviderMessageRequestHandler { sendMessageRequest(message: ProviderMessageRequest): Promise } -export interface ProviderMessageTransport { //extends ProviderMessageRequestHandler { +export interface ProviderMessageTransport { // handleMessage will handle a message received from the remote wallet handleMessage(message: ProviderMessage): void @@ -84,6 +86,12 @@ export enum ProviderMessageType { DEBUG = '_debug' } +export enum ConnectionState { + DISCONNECTED = 0, + CONNECTING = 1, + CONNECTED = 2 +} + export type NetworkEventPayload = NetworkConfig export interface MessageToSign { diff --git a/packages/provider/src/wallet.ts b/packages/provider/src/wallet.ts index eb5211fa6f..4cbb9b5fa5 100644 --- a/packages/provider/src/wallet.ts +++ b/packages/provider/src/wallet.ts @@ -6,8 +6,8 @@ import { Networks, NetworkConfig, WalletContext, sequenceContext, ChainId, getNe import { WalletConfig } from '@0xsequence/config' import { JsonRpcProvider, JsonRpcSigner, ExternalProvider } from '@ethersproject/providers' import { Web3Provider, Web3Signer } from './provider' -import { WindowMessageProvider, ProxyMessageProvider } from './transports' -import { WalletSession, ProviderMessageEvent } from './types' +import { MuxMessageProvider, WindowMessageProvider, ProxyMessageProvider, ProxyMessageChannelPort } from './transports' +import { WalletSession, ProviderMessageEvent, ProviderTransport } from './types' import { WalletCommands } from './commands' export interface WalletProvider { @@ -46,14 +46,18 @@ export class Wallet implements WalletProvider { private session?: WalletSession private transport: { + // top-level provider which connects all transport layers provider?: Web3Provider + // middleware stack for provider router?: JsonRpcRouter allowProvider?: JsonRpcMiddleware cachedProvider?: CachedProvider - + + // message communication + messageProvider?: MuxMessageProvider windowMessageProvider?: WindowMessageProvider - proxyMessageProvider?: ProxyMessageProvider // TODO .. + proxyMessageProvider?: ProxyMessageProvider } private networks: NetworkConfig[] @@ -88,76 +92,60 @@ export class Wallet implements WalletProvider { return } - // TODO: check the config types, if we want proxy, we need method to pass here. - // TODO: higher-order MessageBroadcaster(...transports) should go here, where we send/listen - // on multiple channels.. or.. MessageMux(..transports) .. ie. MessageMux(windowTransport, proxyTransport) - // this.proxyTransportProvider = new ProxyMessageProvider() + // Setup provider + this.transport.messageProvider = new MuxMessageProvider() - // Setup provider - switch (config.type) { - // TODO: loop through types, which are just Window and Proxy - case 'Window': { - - // ..... - this.transport.allowProvider = allowProviderMiddleware((request: JsonRpcRequest): boolean => { - if (request.method === 'sequence_setDefaultChain') return true - - const isLoggedIn = this.isLoggedIn() - if (!isLoggedIn) { - throw new Error('Sequence: not logged in') - } - return isLoggedIn - }) - - // Provider proxy to support middleware stack of logging, caching and read-only rpc calls - this.transport.cachedProvider = new CachedProvider() - - // .. - this.transport.windowMessageProvider = new WindowMessageProvider(this.config.walletAppURL) - this.transport.windowMessageProvider.register() - - // .. - this.transport.router = new JsonRpcRouter([ - loggingProviderMiddleware, - this.transport.allowProvider, - exceptionProviderMiddleware, - this.transport.cachedProvider, - ], this.transport.windowMessageProvider) - - this.transport.provider = new Web3Provider(this.transport.router) - - // below will update the networks automatically when the wallet networks change, however - // this is currently disabled as it may confuse the dapp. Instead the dapp can - // check active networks list from the session and switch the default network - // with useNetwork() explicitly - // - // this.windowTransportProvider.on('networks', networks => { - // this.useNetworks(networks) - // this.saveSession(this.session) - // }) - this.transport.windowMessageProvider.on('accountsChanged', (accounts) => { - if (accounts && accounts.length === 0) { - this.logout() - } - }) - - break - } + // multiple message provider setup, first one to connect will be the main transport + if (this.config.transports?.windowTransport?.enabled) { + this.transport.windowMessageProvider = new WindowMessageProvider(this.config.walletAppURL) + this.transport.messageProvider.add(this.transport.windowMessageProvider) + } + if (this.config.transports?.proxyTransport?.enabled) { + this.transport.proxyMessageProvider = new ProxyMessageProvider(this.config.transports.proxyTransport.appPort) + this.transport.messageProvider.add(this.transport.proxyMessageProvider) + } + this.transport.messageProvider.register() - // TODO: add unsupported message... - case 'Web3Global': { - // TODO: check if window.web3.currentProvider or window.ethereum exists or is set, otherwise return error - // TODO: call window.ethereum.enable() or .connect() + // ..... + this.transport.allowProvider = allowProviderMiddleware((request: JsonRpcRequest): boolean => { + if (request.method === 'sequence_setDefaultChain') return true - // this.provider = new Web3Provider((window as any).ethereum, 'unspecified') // TODO: check the network argument - break + const isLoggedIn = this.isLoggedIn() + if (!isLoggedIn) { + throw new Error('Sequence: not logged in') } + return isLoggedIn + }) + + // Provider proxy to support middleware stack of logging, caching and read-only rpc calls + this.transport.cachedProvider = new CachedProvider() - default: { - throw new Error('unsupported provider type, must be one of ${ProviderType}') + // .. + this.transport.router = new JsonRpcRouter([ + loggingProviderMiddleware, + this.transport.allowProvider, + exceptionProviderMiddleware, + this.transport.cachedProvider, + ], this.transport.messageProvider) + + this.transport.provider = new Web3Provider(this.transport.router) + + // below will update the networks automatically when the wallet networks change, however + // this is currently disabled as it may confuse the dapp. Instead the dapp can + // check active networks list from the session and switch the default network + // with useNetwork() explicitly + // + // this.windowTransportProvider.on('networks', networks => { + // this.useNetworks(networks) + // this.saveSession(this.session) + // }) + + this.transport.messageProvider.on('accountsChanged', (accounts) => { + if (accounts && accounts.length === 0) { + this.logout() } - } + }) // Load existing session from localStorage const session = this.loadSession() @@ -170,35 +158,13 @@ export class Wallet implements WalletProvider { if (refresh === true) { this.logout() } - if (this.isLoggedIn()) { return true } - // TODO: need this to work with multiple transports - // ie. Proxy and Window at same time.. - - // might want to create abstraction above the transports.. for multi.. - - // authenticate - const config = this.config - - switch (config.type) { - case 'Window': { - await this.openWallet('', { login: true }) - const sessionPayload = await this.transport.windowMessageProvider.waitUntilLoggedIn() - this.useSession(sessionPayload) - break - } - - // TODO: remove.. - // .. or, if we keep this, can we use our SDK with injected window.web3? - case 'Web3Global': { - // TODO: for Web3Global, - // window.ethereum.enable() .. - break - } - } + await this.openWallet('', { login: true }) + const sessionPayload = await this.transport.messageProvider.waitUntilLoggedIn() + this.useSession(sessionPayload) return this.isLoggedIn() } @@ -217,11 +183,7 @@ export class Wallet implements WalletProvider { } isConnected(): boolean { - if (this.transport.windowMessageProvider) { - return this.transport.windowMessageProvider.isConnected() - } else { - return false - } + return this.transport.messageProvider.isConnected() } isLoggedIn(): boolean { @@ -271,25 +233,19 @@ export class Wallet implements WalletProvider { throw new Error('login first') } - if (this.transport.windowMessageProvider) { - this.transport.windowMessageProvider.openWallet(path, state) - - await this.transport.windowMessageProvider.waitUntilConnected() + this.transport.messageProvider.openWallet(path, state) + await this.transport.messageProvider.waitUntilConnected() - // setDefaultChain - it's important to send this right away upon connection. This will also - // update the network list in the session each time the wallet is opened & connected. - const networks = await this.transport.provider.send('sequence_setDefaultChain', [this.config.defaultNetworkId]) - this.useNetworks(networks) + // setDefaultChain - it's important to send this right away upon connection. This will also + // update the network list in the session each time the wallet is opened & connected. + const networks = await this.transport.provider.send('sequence_setDefaultChain', [this.config.defaultNetworkId]) + this.useNetworks(networks) - return true - } - return false + return true } closeWallet = (): void => { - if (this.transport.windowMessageProvider) { - this.transport.windowMessageProvider.closeWallet() - } + this.transport.messageProvider.closeWallet() } getProvider(chainId?: ChainId): Web3Provider | undefined { @@ -366,17 +322,11 @@ export class Wallet implements WalletProvider { } on(event: ProviderMessageEvent, fn: (...args: any[]) => void) { - if (!this.transport.windowMessageProvider) { - return - } - this.transport.windowMessageProvider.on(event, fn) + this.transport.messageProvider.on(event, fn) } once(event: ProviderMessageEvent, fn: (...args: any[]) => void) { - if (!this.transport.windowMessageProvider) { - return - } - this.transport.windowMessageProvider.once(event, fn) + this.transport.messageProvider.once(event, fn) } private loadSession = (): WalletSession => { @@ -460,8 +410,6 @@ export class Wallet implements WalletProvider { } export interface ProviderConfig { - type: ProviderType - // Sequence Wallet App URL, default: https://sequence.app walletAppURL: string @@ -469,15 +417,6 @@ export interface ProviderConfig { // WalletContext is returned by the wallet app upon login. walletContext?: WalletContext - // Global web3 provider (optional) - // web3Provider?: ExternalProvider - - // WindowProvider config (optional) - windowTransport?: { - // .. - // timeout?: number - } - // networks is a configuration list of networks used by the wallet. This list // is combined with the network list supplied from the wallet upon login, // and settings here take precedence such as overriding a relayer setting, or rpcUrl. @@ -490,18 +429,29 @@ export interface ProviderConfig { // provider will communicate. Note: this setting is also configurable from the // Wallet constructor's first argument. defaultNetworkId?: string | number -} + // transports for dapp to wallet jron-rpc communication + transports?: { -// TODO: remove Web3Global ..? -export type ProviderType = 'Web3Global' | 'Window' | 'Proxy' // TODO: combo..? ie, window+proxy .. + // WindowMessage transport (optional) + windowTransport?: { + enabled: boolean + } -export const DefaultProviderConfig: ProviderConfig = { - // TODO: check process.env for this if test or production, etc.. - walletAppURL: 'http://localhost:3333', + // ProxyMessage transport (optional) + proxyTransport?: { + enabled: boolean + appPort?: ProxyMessageChannelPort + } - type: 'Window', // TODO: rename.. transports: [] + } +} + +export const DefaultProviderConfig: ProviderConfig = { + walletAppURL: 'https://sequence.app', - windowTransport: { + transports: { + windowTransport: { enabled: true }, + proxyTransport: { enabled: false } } } diff --git a/packages/wallet/tests/wallet.spec.ts b/packages/wallet/tests/wallet.spec.ts index 1c23cf9b29..c3bbfdce16 100644 --- a/packages/wallet/tests/wallet.spec.ts +++ b/packages/wallet/tests/wallet.spec.ts @@ -1075,8 +1075,6 @@ describe('Wallet integration', function () { const message = ethers.utils.toUtf8Bytes('Hi! this is a test message') const digest = ethers.utils.arrayify(ethers.utils.keccak256(message)) - // TODO: Test EIP712 Sign - describe('ethSign', () => { it('Should validate ethSign signature', async () => { const signer = new ethers.Wallet(ethers.utils.randomBytes(32))