-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add provider/signTypedData (#5)
* feat: add provider/signTypedData * chore: update doc comment * chore: document PR
- Loading branch information
Showing
4 changed files
with
560 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.