diff --git a/package-lock.json b/package-lock.json index 6ecb84ee6..4e2449edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "json-bigint": "^1.0.0", "minimalistic-assert": "^1.0.1", "pako": "^2.0.4", + "superstruct": "^0.15.3", "url-join": "^4.0.1" }, "devDependencies": { @@ -33,6 +34,7 @@ "@types/jest": "^27.0.2", "@types/json-bigint": "^1.0.1", "@types/minimalistic-assert": "^1.0.1", + "@types/object-hash": "^2.2.1", "@types/pako": "^1.0.2", "@types/url-join": "^4.0.1", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -3202,6 +3204,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/object-hash": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.2.1.tgz", + "integrity": "sha512-i/rtaJFCsPljrZvP/akBqEwUP2y5cZLOmvO+JaYnz01aPknrQ+hB5MRcO7iqCUsFaYfTG8kGfKUyboA07xeDHQ==", + "dev": true + }, "node_modules/@types/pako": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.2.tgz", @@ -13092,6 +13100,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superstruct": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.3.tgz", + "integrity": "sha512-wilec1Rg3FtKuRjRyCt70g5W29YUEuaLnybdVQUI+VQ7m0bw8k7TzrRv5iYmo6IpjLVrwxP5t3RgjAVqhYh4Fg==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -16293,6 +16306,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "@types/object-hash": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.2.1.tgz", + "integrity": "sha512-i/rtaJFCsPljrZvP/akBqEwUP2y5cZLOmvO+JaYnz01aPknrQ+hB5MRcO7iqCUsFaYfTG8kGfKUyboA07xeDHQ==", + "dev": true + }, "@types/pako": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.2.tgz", @@ -23738,6 +23757,11 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "superstruct": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.3.tgz", + "integrity": "sha512-wilec1Rg3FtKuRjRyCt70g5W29YUEuaLnybdVQUI+VQ7m0bw8k7TzrRv5iYmo6IpjLVrwxP5t3RgjAVqhYh4Fg==" + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index b0bd6551a..fbe7d5f3a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/jest": "^27.0.2", "@types/json-bigint": "^1.0.1", "@types/minimalistic-assert": "^1.0.1", + "@types/object-hash": "^2.2.1", "@types/pako": "^1.0.2", "@types/url-join": "^4.0.1", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -67,6 +68,7 @@ "json-bigint": "^1.0.0", "minimalistic-assert": "^1.0.1", "pako": "^2.0.4", + "superstruct": "^0.15.3", "url-join": "^4.0.1" }, "lint-staged": { diff --git a/src/index.ts b/src/index.ts index ff41a4340..bc50cc65c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,4 @@ export * as stark from './utils/stark'; export * as ec from './utils/ellipticCurve'; export * as uint256 from './utils/uint256'; export * as shortString from './utils/shortString'; +export * as eip712 from './utils/eip712'; diff --git a/src/signer/default.ts b/src/signer/default.ts index 6279a78b6..74890ab32 100644 --- a/src/signer/default.ts +++ b/src/signer/default.ts @@ -1,7 +1,8 @@ import assert from 'minimalistic-assert'; import { Provider } from '../provider'; -import { AddTransactionResponse, KeyPair, Transaction } from '../types'; +import { AddTransactionResponse, KeyPair, Signature, Transaction } from '../types'; +import { TypedData, getMessageHash } from '../utils/eip712'; import { sign } from '../utils/ellipticCurve'; import { addHexPrefix } from '../utils/encode'; import { hashMessage } from '../utils/hash'; @@ -69,4 +70,26 @@ export class Signer extends Provider implements SignerInterface { signature: [r, s], }); } + + /** + * Sign an JSON object with the starknet private key and return the signature + * + * @param json - JSON object to be signed + * @returns the signature of the JSON object + * @throws {Error} if the JSON object is not a valid JSON + */ + public async sign(typedData: TypedData): Promise { + return sign(this.keyPair, await this.hash(typedData)); + } + + /** + * Hash a JSON object with pederson hash and return the hash + * + * @param json - JSON object to be hashed + * @returns the hash of the JSON object + * @throws {Error} if the JSON object is not a valid JSON + */ + public async hash(typedData: TypedData): Promise { + return getMessageHash(typedData, this.address); + } } diff --git a/src/signer/interface.ts b/src/signer/interface.ts index 7159783f0..5191a8e9f 100644 --- a/src/signer/interface.ts +++ b/src/signer/interface.ts @@ -1,5 +1,6 @@ import { Provider } from '../provider'; -import { AddTransactionResponse, Transaction } from '../types'; +import { AddTransactionResponse, Signature, Transaction } from '../types'; +import { TypedData } from '../utils/eip712/types'; export abstract class SignerInterface extends Provider { public abstract address: string; @@ -14,4 +15,22 @@ export abstract class SignerInterface extends Provider { public abstract override addTransaction( transaction: Transaction ): Promise; + + /** + * Sign an JSON object with the starknet private key and return the signature + * + * @param json - JSON object to be signed + * @returns the signature of the JSON object + * @throws {Error} if the JSON object is not a valid JSON + */ + public abstract sign(typedData: TypedData): Promise; + + /** + * Hash a JSON object with pederson hash and return the hash + * + * @param json - JSON object to be hashed + * @returns the hash of the JSON object + * @throws {Error} if the JSON object is not a valid JSON + */ + public abstract hash(typedData: TypedData): Promise; } diff --git a/src/utils/eip712/index.ts b/src/utils/eip712/index.ts new file mode 100644 index 000000000..d1a947f10 --- /dev/null +++ b/src/utils/eip712/index.ts @@ -0,0 +1,169 @@ +import { computeHashOnElements } from '../hash'; +import { BigNumberish } from '../number'; +import { encodeShortString } from '../shortString'; +import { getSelectorFromName } from '../stark'; +import { TypedData } from './types'; +import { validateTypedData } from './utils'; + +export * from './types'; + +/** + * Get the dependencies of a struct type. If a struct has the same dependency multiple times, it's only included once + * in the resulting array. + * + * @param {TypedData} typedData + * @param {string} type + * @param {string[]} [dependencies] + * @return {string[]} + */ +export const getDependencies = ( + typedData: TypedData, + type: string, + dependencies: string[] = [] +): string[] => { + // `getDependencies` is called by most other functions, so we validate the JSON schema here + if (!validateTypedData(typedData)) { + throw new Error('Typed data does not match JSON schema'); + } + + if (dependencies.includes(type)) { + return dependencies; + } + + if (!typedData.types[type]) { + return dependencies; + } + + return [ + type, + ...typedData.types[type].reduce( + (previous, t) => [ + ...previous, + ...getDependencies(typedData, t.type, previous).filter( + (dependency) => !previous.includes(dependency) + ), + ], + [] + ), + ]; +}; + +/** + * Encode a type to a string. All dependant types are alphabetically sorted. + * + * @param {TypedData} typedData + * @param {string} type + * @return {string} + */ +export const encodeType = (typedData: TypedData, type: string): string => { + const [primary, ...dependencies] = getDependencies(typedData, type); + const types = [primary, ...dependencies.sort()]; + + return types + .map((dependency) => { + return `${dependency}(${typedData.types[dependency].map((t) => `${t.type} ${t.name}`)})`; + }) + .join(''); +}; + +/** + * Get a type string as hash. + * + * @param {TypedData} typedData + * @param {string} type + * @return {string} + */ +export const getTypeHash = (typedData: TypedData, type: string): string => { + return getSelectorFromName(encodeType(typedData, type)); +}; + +/** + * Encodes a single value to an ABI serialisable string, number or Buffer. Returns the data as tuple, which consists of + * an array of ABI compatible types, and an array of corresponding values. + * + * @param {TypedData} typedData + * @param {string} type + * @param {any} data + * @returns {[string, string]} + */ +const encodeValue = (typedData: TypedData, type: string, data: unknown): [string, string] => { + if (typedData.types[type]) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return ['felt', getStructHash(typedData, type, data as Record)]; + } + + if (type === 'shortString') { + return ['felt', encodeShortString(data as string)]; + } + + if (type === 'felt*') { + return ['felt', computeHashOnElements(data as string[])]; + } + + return [type, data as string]; +}; + +/** + * Encode the data to an ABI encoded Buffer. The data should be a key -> value object with all the required values. All + * dependant types are automatically encoded. + * + * @param {TypedData} typedData + * @param {string} type + * @param {Record} data + */ +export const encodeData = (typedData: T, type: string, data: T['message']) => { + const [types, values] = typedData.types[type].reduce<[string[], string[]]>( + ([ts, vs], field) => { + if (data[field.name] === undefined || data[field.name] === null) { + throw new Error(`Cannot encode data: missing data for '${field.name}'`); + } + + const value = data[field.name]; + const [t, encodedValue] = encodeValue(typedData, field.type, value); + + return [ + [...ts, t], + [...vs, encodedValue], + ]; + }, + [['felt'], [getTypeHash(typedData, type)]] + ); + + return [types, values]; +}; + +/** + * Get encoded data as a hash. The data should be a key -> value object with all the required values. All dependant + * types are automatically encoded. + * + * @param {TypedData} typedData + * @param {string} type + * @param {Record} data + * @return {Buffer} + */ +export const getStructHash = ( + typedData: T, + type: string, + data: T['message'] +) => { + return computeHashOnElements(encodeData(typedData, type, data)[1]); +}; + +/** + * Get the EIP-191 encoded message to sign, from the typedData object. If `hash` is enabled, the message will be hashed + * with Keccak256. + * + * @param {TypedData} typedData + * @param {boolean} hash + * @return {string} + */ +export const getMessageHash = (typedData: TypedData, account: BigNumberish): string => { + const message = [ + encodeShortString('StarkNet Message'), + getStructHash(typedData, 'EIP712Domain', typedData.domain), + account, + getStructHash(typedData, typedData.primaryType, typedData.message), + ]; + + return computeHashOnElements(message); +}; diff --git a/src/utils/eip712/types.ts b/src/utils/eip712/types.ts new file mode 100644 index 000000000..2a260177c --- /dev/null +++ b/src/utils/eip712/types.ts @@ -0,0 +1,86 @@ +import { + Infer, + array, + intersection, + number, + object, + optional, + pattern, + record, + refine, + string, + type as t, + union, +} from 'superstruct'; + +export const STATIC_TYPES = ['felt', 'felt*', 'shortString']; + +// Source: https://github.com/Mrtenz/eip-712/blob/master/src/eip-712.ts +// and modified to support starknet types + +/** + * Checks if a type is valid with the given `typedData`. The following types are valid: + * - Atomic types: felt, felt* + * - Dynamic types: shortString + * - Reference types: struct type (e.g. SomeStruct) + * + * @param {Record} types + * @param {string} type + * @return {boolean} + */ +export const isValidType = (types: Record, type: string): boolean => { + if (STATIC_TYPES.includes(type as string)) { + return true; + } + + if (types[type]) { + return true; + } + + return false; +}; + +const TYPE = refine(string(), 'Type', (type, context) => { + return isValidType(context.branch[0].types, type); +}); + +export const EIP_712_TYPE = object({ + name: string(), + type: TYPE, +}); + +/** + * A single type, as part of a struct. The `type` field can be any of the EIP-712 supported types. + * + * Note that the `uint` and `int` aliases like in Solidity, and fixed point numbers are not supported by the EIP-712 + * standard. + */ +export type EIP712Type = Infer; + +export const EIP_712_DOMAIN_TYPE = object({ + name: optional(string()), + version: optional(string()), + chainId: optional(union([string(), number()])), + verifyingContract: optional(pattern(string(), /^0x[0-9a-z]{40}$/i)), + salt: optional(union([array(number()), pattern(string(), /^0x[0-9a-z]{64}$/i)])), +}); + +/** + * The EIP712 domain struct. Any of these fields are optional, but it must contain at least one field. + */ +export type EIP712Domain = Infer; + +export const EIP_712_TYPED_DATA_TYPE = object({ + types: intersection([ + t({ EIP712Domain: array(EIP_712_TYPE) }), + record(string(), array(EIP_712_TYPE)), + ]), + primaryType: string(), + domain: EIP_712_DOMAIN_TYPE, + message: object(), +}); + +/** + * The complete typed data, with all the structs, domain data, primary type of the message, and the message itself. + */ +export type TypedData = Infer; diff --git a/src/utils/eip712/utils.ts b/src/utils/eip712/utils.ts new file mode 100644 index 000000000..4b07e02ce --- /dev/null +++ b/src/utils/eip712/utils.ts @@ -0,0 +1,13 @@ +import { is } from 'superstruct'; + +import { EIP_712_TYPED_DATA_TYPE, TypedData } from './types'; + +/** + * Validates that `data` matches the EIP-712 JSON schema. + * + * @param {any} data + * @return {boolean} + */ +export const validateTypedData = (data: unknown): data is TypedData => { + return is(data, EIP_712_TYPED_DATA_TYPE); +};