Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SafePal Wallet #939

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/safepal-wallet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @web3-react/safepal-wallet
36 changes: 36 additions & 0 deletions packages/safepal-wallet/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@web3-react/safepal-wallet",
"keywords": [
"web3-react",
"safepal-wallet"
],
"author": "safepalteam001",
"license": "GPL-3.0-or-later",
"repository": "github:Uniswap/web3-react",
"publishConfig": {
"access": "public"
},
"version": "8.2.2",
"files": [
"dist/*"
],
"type": "commonjs",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"exports": "./dist/index.js",
"scripts": {
"prebuild": "rm -rf dist",
"build": "tsc",
"start": "tsc --watch"
},
"dependencies": {
"@safepal-wallet/web3-sdk": "^1.0.0",
"@web3-react/types": "^8.2.2"
},
"peerDependencies": {
"@coinbase/wallet-sdk": "^3.0.4"
},
"devDependencies": {
"@web3-react/store": "^8.2.2"
}
}
66 changes: 66 additions & 0 deletions packages/safepal-wallet/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createWeb3ReactStoreAndActions } from '@web3-react/store'
import type { Actions, Web3ReactStore } from '@web3-react/types'
import { SafePalWallet } from '.'
import { MockEIP1193Provider } from '@web3-react/core'

const chainId = '0x1'
const accounts: string[] = ['0x0000000000000000000000000000000000000000']

describe('SafePalWallet', () => {
let mockProvider: MockEIP1193Provider

beforeEach(() => {
mockProvider = new MockEIP1193Provider()
})

beforeEach(() => {
;(window as any).ethereum = mockProvider
})

let store: Web3ReactStore
let connector: SafePalWallet

beforeEach(() => {
let actions: Actions
;[store, actions] = createWeb3ReactStoreAndActions()
connector = new SafePalWallet({ actions })
})

test('#connectEagerly', async () => {
mockProvider.chainId = chainId
mockProvider.accounts = accounts

await connector.connectEagerly()

expect(mockProvider.eth_requestAccounts).not.toHaveBeenCalled()
expect(mockProvider.eth_accounts).toHaveBeenCalled()
expect(mockProvider.eth_chainId).toHaveBeenCalled()
expect(mockProvider.eth_chainId.mock.invocationCallOrder[0])
.toBeGreaterThan(mockProvider.eth_accounts.mock.invocationCallOrder[0])

expect(store.getState()).toEqual({
chainId: Number.parseInt(chainId, 16),
accounts,
activating: false,
})
})

test('#activate', async () => {
mockProvider.chainId = chainId
mockProvider.accounts = accounts

await connector.activate()

expect(mockProvider.eth_requestAccounts).toHaveBeenCalled()
expect(mockProvider.eth_accounts).not.toHaveBeenCalled()
expect(mockProvider.eth_chainId).toHaveBeenCalled()
expect(mockProvider.eth_chainId.mock.invocationCallOrder[0])
.toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0])

expect(store.getState()).toEqual({
chainId: Number.parseInt(chainId, 16),
accounts,
activating: false,
})
})
})
208 changes: 208 additions & 0 deletions packages/safepal-wallet/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {
EIP1193Options,
currentProvider,
getDownload,
getIsInstall,
installWalletMessage
} from '@safepal-wallet/web3-sdk'
import type {
Actions,
AddEthereumChainParameter,
Provider,
ProviderConnectInfo,
ProviderRpcError,
WatchAssetParameters,
} from '@web3-react/types'
import { Connector } from '@web3-react/types'

type SafePalWalletProvider = Provider & {
isSafePal?: boolean
isConnected?: () => boolean
providers?: SafePalWalletProvider[]
get chainId(): string
get accounts(): string[]
}

export class NoSafePalWalletError extends Error {
public constructor() {
super('SafePalWallet not installed')
this.name = NoSafePalWalletError.name
Object.setPrototypeOf(this, NoSafePalWalletError.prototype)
}
}

type SafePalWalletConnectorOptions = EIP1193Options & {
/**
* Connector automatically connects when used as Safe App.
*
* This flag simulates the disconnect behavior by keeping track of connection status in storage
* and only autoconnecting when previously connected by user action (e.g. explicitly choosing to connect).
*
* @default false
*/
shimDisconnect?: boolean
}

function parseChainId(chainId: string) {
return Number.parseInt(chainId, 16)
}

/**
* @param options - Options to pass to `@SafePalWallet/detect-provider`
* @param onError - Handler to report errors thrown from eventListeners.
*/
export interface SafePalWalletConstructorArgs {
actions: Actions
options?: SafePalWalletConnectorOptions
onError?: (error: Error) => void
}

export class SafePalWallet extends Connector {
/** {@inheritdoc Connector.provider} */
public provider?: SafePalWalletProvider
private readonly options?: SafePalWalletConnectorOptions

constructor({ actions, options, onError }: SafePalWalletConstructorArgs) {
super(actions, onError)
this.options = options
}

private async isomorphicInitialize(): Promise<void> {
const provider = currentProvider();
if (getIsInstall()) {
this.provider = provider as SafePalWalletProvider
this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => {
this.actions.update({ chainId: parseChainId(chainId) })
})
this.provider.on('disconnect', (error: ProviderRpcError): void => {
// 1013 indicates that SafePalWallet is attempting to reestablish the connection
if (error.code === 1013) {
console.debug('SafePalWallet logged connection error 1013: "Try again later"')
return
}
this.actions.resetState()
this.onError?.(error)
})

this.provider.on('chainChanged', (chainId: string): void => {
this.actions.update({ chainId: parseChainId(chainId) })
})

this.provider.on('accountsChanged', (accounts: string[]): void => {
if (accounts.length === 0) {
// handle this edge case by disconnecting
this.actions.resetState()
} else {
this.actions.update({ accounts })
}
})
} else {
window.open(getDownload(), '_blank')
throw new Error(installWalletMessage)
}
}

/** {@inheritdoc Connector.connectEagerly} */
public async connectEagerly(): Promise<void> {
const cancelActivation = this.actions.startActivation()

try {
await this.isomorphicInitialize()
if (!this.provider) return cancelActivation()

// Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing
// chains; they should be requested serially, with accounts first, so that the chainId can settle.
const accounts = (await this.provider.request({ method: 'eth_accounts' })) as string[]
if (!accounts.length) throw new Error('No accounts returned')
const chainId = (await this.provider.request({ method: 'eth_chainId' })) as string
this.actions.update({ chainId: parseChainId(chainId), accounts })
} catch (error) {
console.debug('Could not connect eagerly', error)
// we should be able to use `cancelActivation` here, but on mobile, SafePal Wallet emits a 'connect'
// event, meaning that chainId is updated, and cancelActivation doesn't work because an intermediary
// update has occurred, so we reset state instead
this.actions.resetState()
}
}

/**
* Initiates a connection.
*
* @param desiredChainIdOrChainParameters - If defined, indicates the desired chain to connect to. If the user is
* already connected to this chain, no additional steps will be taken. Otherwise, the user will be prompted to switch
* to the chain, if one of two conditions is met: either they already have it added in their extension, or the
* argument is of type AddEthereumChainParameter, in which case the user will be prompted to add the chain with the
* specified parameters first, before being prompted to switch.
*/
public async activate(desiredChainIdOrChainParameters?: number | AddEthereumChainParameter): Promise<void> {
let cancelActivation: () => void
if (!this.provider?.isConnected?.()) cancelActivation = this.actions.startActivation()

return this.isomorphicInitialize()
.then(async () => {
if (!this.provider) throw new NoSafePalWalletError()

// Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing
// chains; they should be requested serially, with accounts first, so that the chainId can settle.
const accounts = (await this.provider.request({ method: 'eth_requestAccounts' })) as string[]
const chainId = (await this.provider.request({ method: 'eth_chainId' })) as string
const receivedChainId = parseChainId(chainId)
const desiredChainId =
typeof desiredChainIdOrChainParameters === 'number'
? desiredChainIdOrChainParameters
: desiredChainIdOrChainParameters?.chainId

// if there's no desired chain, or it's equal to the received, update
if (!desiredChainId || receivedChainId === desiredChainId)
return this.actions.update({ chainId: receivedChainId, accounts })

const desiredChainIdHex = `0x${desiredChainId.toString(16)}`

// if we're here, we can try to switch networks
return this.provider
.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: desiredChainIdHex }],
})
.catch((error: ProviderRpcError) => {
const errorCode = (error.data as any)?.originalError?.code || error.code
if (errorCode === 4902 && typeof desiredChainIdOrChainParameters !== 'number') {
if (!this.provider) throw new Error('No provider')
// if we're here, we can try to add a new network
return this.provider.request({
method: 'wallet_addEthereumChain',
params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }],
})
}
throw error
})
.then(() => this.activate(desiredChainId))
})
.catch((error) => {
cancelActivation?.()
throw error
})
}

public async watchAsset({ address, symbol, decimals, image }: WatchAssetParameters): Promise<true> {
if (!this.provider) throw new Error('No provider')

return this.provider
.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20', // Initially only supports ERC20, but eventually more!
options: {
address, // The address that the token is at.
symbol, // A ticker symbol or shorthand, up to 5 chars.
decimals, // The number of decimals in the token
image, // A string url of the token logo
},
},
})
.then((success) => {
if (!success) throw new Error('Rejected')
return true
})
}
}
7 changes: 7 additions & 0 deletions packages/safepal-wallet/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"outDir": "./dist"
}
}