Initial Signer support for EIP-712 signed typed data (#687).

This commit is contained in:
Richard Moore 2020-10-18 21:52:25 -04:00
parent 3e676f21b0
commit be4e2164e6
No known key found for this signature in database
GPG Key ID: 665176BE8E9DC651
4 changed files with 240 additions and 38 deletions

View File

@ -49,6 +49,12 @@ export interface ExternallyOwnedAccount {
// key or mnemonic) in a function, so that console.log does not leak
// the data
// @TODO: This is a temporary measure to preserse backwards compatibility
// In v6, the method on TypedDataSigner will be added to Signer
export interface TypedDataSigner {
_signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string>;
}
export abstract class Signer {
readonly provider?: Provider;
@ -70,8 +76,6 @@ export abstract class Signer {
// it does, sentTransaction MUST be overridden.
abstract signTransaction(transaction: Deferrable<TransactionRequest>): Promise<string>;
// abstract _signTypedData(domain: TypedDataDomain, types: Array<TypedDataField>, data: any): Promise<string>;
// Returns a new instance of the Signer, connected to provider.
// This MAY throw if changing providers is not supported.
abstract connect(provider: Provider): Signer;
@ -236,7 +240,7 @@ export abstract class Signer {
}
}
export class VoidSigner extends Signer {
export class VoidSigner extends Signer implements TypedDataSigner {
readonly address: string;
constructor(address: string, provider?: Provider) {
@ -264,7 +268,7 @@ export class VoidSigner extends Signer {
return this._fail("VoidSigner cannot sign transactions", "signTransaction");
}
_signTypedData(domain: TypedDataDomain, types: Array<TypedDataField>, data: any): Promise<string> {
_signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string> {
return this._fail("VoidSigner cannot sign typed data", "signTypedData");
}

View File

@ -1,9 +1,9 @@
import { TypedDataDomain, TypedDataField } from "@ethersproject/abstract-signer";
import { getAddress } from "@ethersproject/address";
import { BigNumber, BigNumberish } from "@ethersproject/bignumber";
import { arrayify, BytesLike, hexConcat, hexlify, hexZeroPad } from "@ethersproject/bytes";
import { arrayify, BytesLike, hexConcat, hexlify, hexValue, hexZeroPad, isHexString } from "@ethersproject/bytes";
import { keccak256 } from "@ethersproject/keccak256";
import { deepCopy, defineReadOnly } from "@ethersproject/properties";
import { deepCopy, defineReadOnly, shallowCopy } from "@ethersproject/properties";
import { Logger } from "@ethersproject/logger";
import { version } from "./_version";
@ -43,32 +43,63 @@ const domainFieldNames: Array<string> = [
"name", "version", "chainId", "verifyingContract", "salt"
];
function checkString(key: string): (value: any) => string {
return function (value: any){
if (typeof(value) !== "string") {
logger.throwArgumentError(`invalid domain value for ${ JSON.stringify(key) }`, `domain.${ key }`, value);
}
return value;
}
}
const domainChecks: Record<string, (value: any) => any> = {
name: checkString("name"),
version: checkString("version"),
chainId: function(value: any) {
try {
return BigNumber.from(value).toString()
} catch (error) { }
return logger.throwArgumentError(`invalid domain value for "chainId"`, "domain.chainId", value);
},
verifyingContract: function(value: any) {
try {
return getAddress(value).toLowerCase();
} catch (error) { }
return logger.throwArgumentError(`invalid domain value "verifyingContract"`, "domain.verifyingContract", value);
},
salt: function(value: any) {
try {
const bytes = arrayify(value);
if (bytes.length !== 32) { throw new Error("bad length"); }
return hexlify(bytes);
} catch (error) { }
return logger.throwArgumentError(`invalid domain value "salt"`, "domain.salt", value);
}
}
function getBaseEncoder(type: string): (value: any) => string {
// intXX and uintXX
{
const match = type.match(/^(u?)int(\d+)$/);
const match = type.match(/^(u?)int(\d*)$/);
if (match) {
const width = parseInt(match[2]);
if (width % 8 !== 0 || width > 256 || match[2] !== String(width)) {
logger.throwArgumentError("invalid numeric width", "type", type);
}
const signed = (match[1] === "");
return function(value: BigNumberish) {
let v = BigNumber.from(value);
const width = parseInt(match[2] || "256");
if (width % 8 !== 0 || width > 256 || (match[2] && match[2] !== String(width))) {
logger.throwArgumentError("invalid numeric width", "type", type);
}
if (signed) {
let bounds = MaxUint256.mask(width - 1);
if (v.gt(bounds) || v.lt(bounds.add(One).mul(NegativeOne))) {
logger.throwArgumentError(`value out-of-bounds for ${ type }`, "value", value);
}
} else if (v.lt(Zero) || v.gt(MaxUint256.mask(width))) {
const boundsUpper = MaxUint256.mask(signed ? (width - 1): width);
const boundsLower = signed ? boundsUpper.add(One).mul(NegativeOne): Zero;
return function(value: BigNumberish) {
const v = BigNumber.from(value);
if (v.lt(boundsLower) || v.gt(boundsUpper)) {
logger.throwArgumentError(`value out-of-bounds for ${ type }`, "value", value);
}
v = v.toTwos(256);
return hexZeroPad(v.toHexString(), 32);
return hexZeroPad(v.toTwos(256).toHexString(), 32);
};
}
}
@ -81,6 +112,7 @@ function getBaseEncoder(type: string): (value: any) => string {
if (width === 0 || width > 32 || match[1] !== String(width)) {
logger.throwArgumentError("invalid bytes width", "type", type);
}
return function(value: BytesLike) {
const bytes = arrayify(value);
if (bytes.length !== width) {
@ -110,7 +142,7 @@ function getBaseEncoder(type: string): (value: any) => string {
}
function encodeType(name: string, fields: Array<TypedDataField>): string {
return `${ name }(${ fields.map((f) => (f.type + " " + f.name)).join(",") })`;
return `${ name }(${ fields.map(({ name, type }) => (type + " " + name)).join(",") })`;
}
export class TypedDataEncoder {
@ -226,7 +258,7 @@ export class TypedDataEncoder {
_getEncoder(type: string): (value: any) => string {
// Basic encoder type
// Basic encoder type (address, bool, uint256, etc)
{
const encoder = getBaseEncoder(type);
if (encoder) { return encoder; }
@ -257,9 +289,9 @@ export class TypedDataEncoder {
if (fields) {
const encodedType = id(this._types[type]);
return (value: Record<string, any>) => {
const values = fields.map((f) => {
const result = this.getEncoder(f.type)(value[f.name]);
if (this._types[f.type]) { return keccak256(result); }
const values = fields.map(({ name, type }) => {
const result = this.getEncoder(type)(value[name]);
if (this._types[type]) { return keccak256(result); }
return result;
});
values.unshift(encodedType);
@ -294,6 +326,40 @@ export class TypedDataEncoder {
return this.hashStruct(this.primaryType, value);
}
_visit(type: string, value: any, callback: (type: string, data: any) => any): any {
// Basic encoder type (address, bool, uint256, etc)
{
const encoder = getBaseEncoder(type);
if (encoder) { return callback(type, value); }
}
// Array
const match = type.match(/^(.*)(\x5b(\d*)\x5d)$/);
if (match) {
const subtype = match[1];
const length = parseInt(match[3]);
if (length >= 0 && value.length !== length) {
logger.throwArgumentError("array length mismatch; expected length ${ arrayLength }", "value", value);
}
return value.map((v: any) => this._visit(subtype, v, callback));
}
// Struct
const fields = this.types[type];
if (fields) {
return fields.reduce((accum, { name, type }) => {
accum[name] = this._visit(type, value[name], callback);
return accum;
}, <Record<string, any>>{});
}
return logger.throwArgumentError(`unknown type: ${ type }`, "type", type);
}
visit(value: Record<string, any>, callback: (type: string, data: any) => any): any {
return this._visit(this.primaryType, value, callback);
}
static from(types: Record<string, Array<TypedDataField>>): TypedDataEncoder {
return new TypedDataEncoder(types);
}
@ -334,5 +400,112 @@ export class TypedDataEncoder {
static hash(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): string {
return keccak256(TypedDataEncoder.encode(domain, types, value));
}
// Replaces all address types with ENS names with their looked up address
static async resolveNames(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>, resolveName: (name: string) => Promise<string>): Promise<{ domain: TypedDataDomain, value: any }> {
// Make a copy to isolate it from the object passed in
domain = shallowCopy(domain);
// Look up all ENS names
const ensCache: Record<string, string> = { };
// Do we need to look up the domain's verifyingContract?
if (domain.verifyingContract && !isHexString(domain.verifyingContract, 20)) {
ensCache[domain.verifyingContract] = "0x";
}
// We are going to use the encoder to visit all the base values
const encoder = TypedDataEncoder.from(types);
// Get a list of all the addresses
encoder.visit(value, (type: string, value: any) => {
if (type === "address" && !isHexString(value, 20)) {
ensCache[value] = "0x";
}
return value;
});
// Lookup each name
for (const name in ensCache) {
ensCache[name] = await resolveName(name);
}
// Replace the domain verifyingContract if needed
if (domain.verifyingContract && ensCache[domain.verifyingContract]) {
domain.verifyingContract = ensCache[domain.verifyingContract];
}
// Replace all ENS names with their address
value = encoder.visit(value, (type: string, value: any) => {
if (type === "address" && ensCache[value]) { return ensCache[value]; }
return value;
});
return { domain, value };
}
static getPayload(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): any {
// Validate the domain fields
TypedDataEncoder.hashDomain(domain);
// Derive the EIP712Domain Struct reference type
const domainValues: Record<string, any> = { };
const domainTypes: Array<{ name: string, type:string }> = [ ];
domainFieldNames.forEach((name) => {
const value = (<any>domain)[name];
if (value == null) { return; }
domainValues[name] = domainChecks[name](value);
domainTypes.push({ name, type: domainFieldTypes[name] });
});
const encoder = TypedDataEncoder.from(types);
const typesWithDomain = shallowCopy(types);
if (typesWithDomain.EIP712Domain) {
typesWithDomain.EIP712Domain = domainTypes;
}
// Validate the data structures and types
encoder.encode(value);
return {
types: typesWithDomain,
domain: domainValues,
primaryType: encoder.primaryType,
message: encoder.visit(value, (type: string, value: any) => {
// bytes
if (type.match(/^bytes(\d*)/)) {
return hexlify(arrayify(value));
}
// uint or int
if (type.match(/^u?int/)) {
let prefix = "";
let v = BigNumber.from(value);
if (v.isNegative()) {
prefix = "-";
v = v.mul(-1);
}
return prefix + hexValue(v.toHexString());
}
switch (type) {
case "address":
return value.toLowerCase();
case "bool":
return !!value;
case "string":
if (typeof(value) !== "string") {
logger.throwArgumentError(`invalid string`, "value", value);
}
return value;
}
return logger.throwArgumentError("unsupported type", "type", type);
})
};
}
}

View File

@ -3,9 +3,10 @@
// See: https://github.com/ethereum/wiki/wiki/JSON-RPC
import { Provider, TransactionRequest, TransactionResponse } from "@ethersproject/abstract-provider";
import { Signer } from "@ethersproject/abstract-signer";
import { Signer, TypedDataDomain, TypedDataField, TypedDataSigner } from "@ethersproject/abstract-signer";
import { BigNumber } from "@ethersproject/bignumber";
import { Bytes, hexlify, hexValue } from "@ethersproject/bytes";
import { _TypedDataEncoder } from "@ethersproject/hash";
import { Network, Networkish } from "@ethersproject/networks";
import { checkProperties, deepCopy, Deferrable, defineReadOnly, getStatic, resolveProperties, shallowCopy } from "@ethersproject/properties";
import { toUtf8Bytes } from "@ethersproject/strings";
@ -88,7 +89,7 @@ function getLowerCase(value: string): string {
const _constructorGuard = {};
export class JsonRpcSigner extends Signer {
export class JsonRpcSigner extends Signer implements TypedDataSigner {
readonly provider: JsonRpcProvider;
_index: number;
_address: string;
@ -203,13 +204,23 @@ export class JsonRpcSigner extends Signer {
});
}
signMessage(message: Bytes | string): Promise<string> {
async signMessage(message: Bytes | string): Promise<string> {
const data = ((typeof(message) === "string") ? toUtf8Bytes(message): message);
return this.getAddress().then((address) => {
const address = await this.getAddress();
// https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_sign
return this.provider.send("eth_sign", [ address.toLowerCase(), hexlify(data) ]);
// https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_sign
return await this.provider.send("eth_sign", [ address.toLowerCase(), hexlify(data) ]);
}
async _signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string> {
// Populate any ENS names (in-place)
const populated = await _TypedDataEncoder.resolveNames(domain, types, value, (name: string) => {
return this.provider.resolveName(name);
});
return await this.provider.send("eth_signTypedData_v4", [
_TypedDataEncoder.getPayload(populated.domain, types, populated.value)
]);
}
unlock(password: string): Promise<boolean> {

View File

@ -2,9 +2,9 @@
import { getAddress } from "@ethersproject/address";
import { Provider, TransactionRequest } from "@ethersproject/abstract-provider";
import { ExternallyOwnedAccount, Signer } from "@ethersproject/abstract-signer";
import { ExternallyOwnedAccount, Signer, TypedDataDomain, TypedDataField, TypedDataSigner } from "@ethersproject/abstract-signer";
import { arrayify, Bytes, BytesLike, concat, hexDataSlice, isHexString, joinSignature, SignatureLike } from "@ethersproject/bytes";
import { hashMessage } from "@ethersproject/hash";
import { hashMessage, _TypedDataEncoder } from "@ethersproject/hash";
import { defaultPath, HDNode, entropyToMnemonic, Mnemonic } from "@ethersproject/hdnode";
import { keccak256 } from "@ethersproject/keccak256";
import { defineReadOnly, resolveProperties } from "@ethersproject/properties";
@ -27,7 +27,7 @@ function hasMnemonic(value: any): value is { mnemonic: Mnemonic } {
return (mnemonic && mnemonic.phrase);
}
export class Wallet extends Signer implements ExternallyOwnedAccount {
export class Wallet extends Signer implements ExternallyOwnedAccount, TypedDataSigner {
readonly address: string;
readonly provider: Provider;
@ -119,8 +119,22 @@ export class Wallet extends Signer implements ExternallyOwnedAccount {
});
}
signMessage(message: Bytes | string): Promise<string> {
return Promise.resolve(joinSignature(this._signingKey().signDigest(hashMessage(message))));
async signMessage(message: Bytes | string): Promise<string> {
return joinSignature(this._signingKey().signDigest(hashMessage(message)));
}
async _signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string> {
// Populate any ENS names
const populated = await _TypedDataEncoder.resolveNames(domain, types, value, (name: string) => {
if (this.provider == null) {
logger.throwError("cannot resolve ENS names without a provider", Logger.errors.UNSUPPORTED_OPERATION, {
operation: "resolveName"
});
}
return this.provider.resolveName(name);
});
return joinSignature(this._signingKey().signDigest(_TypedDataEncoder.hash(populated.domain, types, populated.value)));
}
encrypt(password: Bytes | string, options?: any, progressCallback?: ProgressCallback): Promise<string> {