Skip to content

Commit

Permalink
feat: add provider/signTypedData (#5)
Browse files Browse the repository at this point in the history
* feat: add provider/signTypedData

* chore: update doc comment

* chore: document PR
  • Loading branch information
zzmp authored Jan 20, 2023
1 parent a32a8a4 commit 035e0dd
Show file tree
Hide file tree
Showing 4 changed files with 560 additions and 8 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"ethers": "^5.6.1",
"jest": "^29.3.0",
"prettier": "^2.2.1",
"react": "^18.2.0",
"semantic-release": "^19.0.2",
"typescript": "^4.4.3"
},
"peerDependencies": {
"@uniswap/sdk-core": ">=3"
"@uniswap/sdk-core": ">=3",
"ethers": "^5.6.1"
}
}
95 changes: 95 additions & 0 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { JsonRpcProvider } from '@ethersproject/providers'

import { INVALID_PARAMS_CODE, signTypedData } from './provider'

describe('provider', () => {
describe('signTypedData', () => {
const wallet = '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'
const domain = {
name: 'Ether Mail',
version: '1',
chainId: '1',
verifyingContract: '0xcccccccccccccccccccccccccccccccccccccccc',
}

const types = {
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
],
}

const value = {
from: {
name: 'Cow',
wallet,
},
to: {
name: 'Bob',
wallet: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
contents: 'Hello, Bob!',
}

let signer
beforeEach(() => {
signer = new JsonRpcProvider().getSigner()
jest.spyOn(signer, 'getAddress').mockReturnValue(wallet)
})

it('signs using eth_signTypedData', async () => {
const send = jest.spyOn(signer.provider, 'send').mockImplementationOnce((method, params) => {
if (method === 'eth_signTypedData') return Promise.resolve()
})

await signTypedData(signer, domain, types, value)
expect(send).toHaveBeenCalledWith('eth_signTypedData', [wallet, expect.anything()])
const data = send.mock.lastCall[1]?.[1]
expect(JSON.parse(data)).toEqual(expect.objectContaining({ domain, message: value }))
})

it('falls back to eth_signTypedData_v4 if the request has invalid params', async () => {
const send = jest
.spyOn(signer.provider, 'send')
.mockImplementationOnce((method) => {
if (method === 'eth_signTypedData') return Promise.reject({ code: INVALID_PARAMS_CODE })
})
.mockImplementationOnce((method, params) => {
if (method === 'eth_signTypedData_v4') return Promise.resolve(params)
})
jest.spyOn(console, 'warn').mockImplementation(() => undefined)

await signTypedData(signer, domain, types, value)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('eth_signTypedData failed'), expect.anything())
expect(send).toHaveBeenCalledWith('eth_signTypedData', [wallet, expect.anything()])
const data = send.mock.lastCall[1]?.[1]
expect(JSON.parse(data)).toEqual(expect.objectContaining({ domain, message: value }))
})

it('falls back to eth_sign if eth_signTypedData is unimplemented', async () => {
const send = jest
.spyOn(signer.provider, 'send')
.mockImplementationOnce((method) => {
if (method === 'eth_signTypedData') return Promise.reject({ message: 'method not found' })
})
.mockImplementationOnce((method, params) => {
if (method === 'eth_sign') return Promise.resolve(params)
})
jest.spyOn(console, 'warn').mockImplementation(() => undefined)

await signTypedData(signer, domain, types, value)
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('eth_signTypedData_* failed'),
expect.anything()
)
expect(send).toHaveBeenCalledWith('eth_sign', [wallet, expect.anything()])
const hash = send.mock.lastCall[1]?.[1]
expect(hash).toBe('0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2')
})
})
})
55 changes: 55 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { TypedDataDomain, TypedDataField } from '@ethersproject/abstract-signer'
import { _TypedDataEncoder } from '@ethersproject/hash'
import type { JsonRpcSigner } from '@ethersproject/providers'

// See https://github.com/MetaMask/eth-rpc-errors/blob/b19c8724168eec4ce3f8b1f87642f231f0dd27b2/src/error-constants.ts#L12
export const INVALID_PARAMS_CODE = -32602

/**
* Calls into the eth_signTypedData methods to add support for wallets with spotty EIP-712 support (eg Safepal) or without any (eg Zerion),
* by first trying eth_signTypedData, and then falling back to either eth_signTyepdData_v4 or eth_sign.
* The implementation is copied from ethers (and linted).
* @see https://github.com/ethers-io/ethers.js/blob/c80fcddf50a9023486e9f9acb1848aba4c19f7b6/packages/providers/src.ts/json-rpc-provider.ts#L334
* TODO(https://github.com/ethers-io/ethers.js/pull/3667): Remove if upstreamed.
*/
export async function signTypedData(
signer: JsonRpcSigner,
domain: TypedDataDomain,
types: Record<string, TypedDataField[]>,
value: Record<string, unknown>
) {
// Populate any ENS names (in-place)
const populated = await _TypedDataEncoder.resolveNames(domain, types, value, (name: string) => {
return signer.provider.resolveName(name) as Promise<string>
})

const address = await signer.getAddress()

try {
try {
// We must try the unversioned eth_signTypedData first, because some wallets (eg SafePal) will hang on _v4.
return await signer.provider.send('eth_signTypedData', [
address.toLowerCase(),
JSON.stringify(_TypedDataEncoder.getPayload(populated.domain, types, populated.value)),
])
} catch (error) {
// MetaMask complains that the unversioned eth_signTypedData is formatted incorrectly (32602) - it prefers _v4.
if (error.code === INVALID_PARAMS_CODE) {
console.warn('eth_signTypedData failed, falling back to eth_signTypedData_v4:', error)
return await signer.provider.send('eth_signTypedData_v4', [
address.toLowerCase(),
JSON.stringify(_TypedDataEncoder.getPayload(populated.domain, types, populated.value)),
])
}
throw error
}
} catch (error) {
// If neither other method is available (eg Zerion), fallback to eth_sign.
if (typeof error.message === 'string' && error.message.match(/not found/i)) {
console.warn('eth_signTypedData_* failed, falling back to eth_sign:', error)
const hash = _TypedDataEncoder.hash(populated.domain, types, populated.value)
return await signer.provider.send('eth_sign', [address, hash])
}
throw error
}
}
Loading

0 comments on commit 035e0dd

Please sign in to comment.