diff --git a/package.json b/package.json index d9afe4b..653b4f0 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "fixtures:generate": "node scripts/generate-tx-fixtures.cjs" }, "dependencies": { + "@casper-ecosystem/casper-eip-712": "1.2.1", "@noble/hashes": "^1.8.0", "apisauce": "^3.2.2", "casper-js-sdk": "5.0.12", diff --git a/src/data/repositories/eip712/eip712.test.ts b/src/data/repositories/eip712/eip712.test.ts new file mode 100644 index 0000000..f587e39 --- /dev/null +++ b/src/data/repositories/eip712/eip712.test.ts @@ -0,0 +1,65 @@ +import { PrivateKey, KeyAlgorithm } from 'casper-js-sdk'; +import { PermitTypes, buildDomain } from '@casper-ecosystem/casper-eip-712'; +import { EIP712Repository } from './index'; +import { EIP712Error, isEIP712Error } from '../../../domain'; + +const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); +const MESSAGE = { + owner: '0x00' + '02'.repeat(32), + spender: '0x01' + '03'.repeat(32), + value: 1000n, + nonce: 0n, + deadline: 1999999999n, +}; +const TYPED_DATA = { domain: DOMAIN, types: PermitTypes, primaryType: 'Permit', message: MESSAGE }; +const EXPECTED_DIGEST = '0x545a8088d6365ada6ef282f4d5979c655cd428b4115cbf65f561bcd65767a98c'; + +describe('EIP712Repository', () => { + const repo = new EIP712Repository(); + + it('computeDigest delegates to the digest util', () => { + expect(repo.computeDigest(TYPED_DATA).digest).toBe(EXPECTED_DIGEST); + }); + + it('buildDisplayModel returns rows', () => { + const model = repo.buildDisplayModel(TYPED_DATA); + expect(model.primaryType).toBe('Permit'); + expect(model.messageRows).toHaveLength(5); + }); + + it('signDigest produces a 02-prefixed secp256k1 signature', () => { + const key = PrivateKey.fromHex('11'.repeat(32), KeyAlgorithm.SECP256K1); + const result = repo.signDigest({ privateKey: key, digest: EXPECTED_DIGEST }); + expect(result.signature.startsWith('02')).toBe(true); + expect(result.digest).toBe(EXPECTED_DIGEST); + }); + + it('signTypedData computes then signs', () => { + const key = PrivateKey.fromHex('11'.repeat(32), KeyAlgorithm.SECP256K1); + const result = repo.signTypedData({ typedData: TYPED_DATA, privateKey: key }); + expect(result.digest).toBe(EXPECTED_DIGEST); + }); + + it('preserves an already-typed EIP712Error (pass-through)', () => { + const broken = { ...TYPED_DATA, primaryType: 'Missing' }; + let caught: unknown; + try { + repo.computeDigest(broken); + } catch (e) { + caught = e; + } + expect(isEIP712Error(caught)).toBe(true); + }); + + it('wraps a non-EIP712 lib failure as EIP712Error with type computeDigest', () => { + const malformed = { ...TYPED_DATA, message: { ...MESSAGE, owner: '0x1234' } }; + let caught: unknown; + try { + repo.computeDigest(malformed); + } catch (e) { + caught = e; + } + expect(isEIP712Error(caught)).toBe(true); + expect((caught as EIP712Error).type).toBe('computeDigest'); + }); +}); diff --git a/src/data/repositories/eip712/index.ts b/src/data/repositories/eip712/index.ts new file mode 100644 index 0000000..fdde509 --- /dev/null +++ b/src/data/repositories/eip712/index.ts @@ -0,0 +1,68 @@ +import { + EIP712Error, + IEIP712Digest, + IEIP712DisplayModel, + IEIP712RecoverSignerParams, + IEIP712Repository, + IEIP712SignDigestParams, + IEIP712SignResult, + IEIP712SignTypedDataOptions, + IEIP712SignTypedDataParams, + IEIP712TypedData, + IEIP712VerifySignatureParams, + isEIP712Error, +} from '../../../domain'; +import { + buildTypedDataDisplayModel, + computeTypedDataDigest, + recoverTypedDataSignerAddress, + signTypedData as signTypedDataUtil, + signTypedDataDigestWithKey, + verifyTypedDataSignature, +} from '../../../utils'; + +/** + * Synchronous repository: EIP-712 work is pure CPU (validation, hashing, signing) with no network + * or I/O, so — unlike the HTTP-backed repositories in this package — these methods are not async. + * + * `computeDigest`, `signDigest` and `signTypedData` wrap unexpected failures in {@link EIP712Error}. + * `recoverSigner` and `verifySignature` are thin secp256k1 wrappers that surface raw library errors + * as-is (they are not wrapped in EIP712Error). + */ +export class EIP712Repository implements IEIP712Repository { + computeDigest(typedData: IEIP712TypedData, options?: IEIP712SignTypedDataOptions): IEIP712Digest { + try { + return computeTypedDataDigest(typedData, options); + } catch (e) { + throw isEIP712Error(e) ? e : new EIP712Error(e, 'computeDigest'); + } + } + + buildDisplayModel(typedData: IEIP712TypedData): IEIP712DisplayModel { + return buildTypedDataDisplayModel(typedData); + } + + signDigest({ privateKey, digest }: IEIP712SignDigestParams): IEIP712SignResult { + try { + return signTypedDataDigestWithKey(privateKey, digest); + } catch (e) { + throw isEIP712Error(e) ? e : new EIP712Error(e, 'signDigest'); + } + } + + signTypedData({ typedData, privateKey, options }: IEIP712SignTypedDataParams): IEIP712SignResult { + try { + return signTypedDataUtil(typedData, privateKey, options); + } catch (e) { + throw isEIP712Error(e) ? e : new EIP712Error(e, 'signTypedData'); + } + } + + recoverSigner(params: IEIP712RecoverSignerParams): string { + return recoverTypedDataSignerAddress(params); + } + + verifySignature(params: IEIP712VerifySignatureParams): boolean { + return verifyTypedDataSignature(params); + } +} diff --git a/src/data/repositories/index.ts b/src/data/repositories/index.ts index 3caa464..9b2155f 100644 --- a/src/data/repositories/index.ts +++ b/src/data/repositories/index.ts @@ -7,3 +7,4 @@ export * from './accountInfo'; export * from './appEvents'; export * from './txSignatureRequest'; export * from './contractPackage'; +export * from './eip712'; diff --git a/src/domain/eip712/entities.ts b/src/domain/eip712/entities.ts new file mode 100644 index 0000000..6c16941 --- /dev/null +++ b/src/domain/eip712/entities.ts @@ -0,0 +1,60 @@ +import { Maybe } from '../../typings'; + +export interface IEIP712Field { + name: string; + type: string; +} + +export type IEIP712Types = Record; + +export interface IEIP712TypedData { + domain: Record; + types: IEIP712Types; + primaryType: string; + message: Record; +} + +export interface IEIP712SignTypedDataOptions { + domainTypes?: IEIP712Field[]; + returnHashArtifacts?: boolean; + rejectUnknownFields?: boolean; +} + +export interface IEIP712HashArtifacts { + domainTypeString: string; + domain: Record; + domainSeparator: string; + structHash: string; + canonicalTypeString: string; + typeHash: string; +} + +export interface IEIP712Digest { + digest: string; + resolvedDomainTypes: IEIP712Field[]; + hashArtifacts?: IEIP712HashArtifacts; +} + +export interface IEIP712DisplayRow { + label: string; + value: string; + displayValue: string; + isAddress: boolean; + copyValue: Maybe; + type: string; +} + +export interface IEIP712DisplayModel { + domainRows: IEIP712DisplayRow[]; + messageRows: IEIP712DisplayRow[]; + primaryType: string; +} + +export type EIP712SignatureScheme = 'ed25519' | 'secp256k1'; + +export interface IEIP712SignResult { + signature: string; + digest: string; + publicKey: string; + hashArtifacts?: IEIP712HashArtifacts; +} diff --git a/src/domain/eip712/errors.test.ts b/src/domain/eip712/errors.test.ts new file mode 100644 index 0000000..4514df0 --- /dev/null +++ b/src/domain/eip712/errors.test.ts @@ -0,0 +1,23 @@ +import { EIP712Error, isEIP712Error, SignTypedDataErrorCodes } from './errors'; + +describe('EIP712Error', () => { + it('carries type, errorCode and a public name', () => { + const err = new EIP712Error( + new Error('boom'), + 'validateTypedData', + SignTypedDataErrorCodes.INVALID_PARAMS, + ); + + expect(err.message).toBe('boom'); + expect(err.type).toBe('validateTypedData'); + expect(err.errorCode).toBe('INVALID_PARAMS'); + expect(err.name).toBe('EIP712Error'); + expect(err.traceable).toBe(true); + }); + + it('isEIP712Error narrows correctly', () => { + const err = new EIP712Error(new Error('x'), 'computeDigest'); + expect(isEIP712Error(err)).toBe(true); + expect(isEIP712Error(new Error('plain'))).toBe(false); + }); +}); diff --git a/src/domain/eip712/errors.ts b/src/domain/eip712/errors.ts new file mode 100644 index 0000000..e7b4719 --- /dev/null +++ b/src/domain/eip712/errors.ts @@ -0,0 +1,45 @@ +import { IDomainError, isDomainError, isError } from '../common'; + +export const SignTypedDataErrorCodes = { + INVALID_PARAMS: 'INVALID_PARAMS', + DOMAIN_TYPES_REQUIRED: 'DOMAIN_TYPES_REQUIRED', + UNSUPPORTED_TYPE: 'UNSUPPORTED_TYPE', +} as const; +export type SignTypedDataErrorCode = + (typeof SignTypedDataErrorCodes)[keyof typeof SignTypedDataErrorCodes]; + +export type EIP712ErrorType = + | 'validateTypedData' + | 'resolveDomainTypes' + | 'computeDigest' + | 'signDigest' + | 'signTypedData'; + +export type IEIP712Error = IDomainError & { + errorCode?: SignTypedDataErrorCode; +}; + +export function isEIP712Error(error: unknown | IEIP712Error): error is IEIP712Error { + return error instanceof EIP712Error && (error).name === 'EIP712Error'; +} + +export class EIP712Error extends Error implements IEIP712Error { + constructor(error: Error | unknown, type: EIP712ErrorType, errorCode?: SignTypedDataErrorCode) { + if (isError(error)) { + super(error.message); + this.stack = error.stack; + this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; + } else { + super(JSON.stringify(error)); + this.traceable = true; + } + + this.name = 'EIP712Error'; + this.type = type; + this.errorCode = errorCode; + } + + type: EIP712ErrorType; + traceable: boolean; + errorCode?: SignTypedDataErrorCode; +} diff --git a/src/domain/eip712/index.ts b/src/domain/eip712/index.ts new file mode 100644 index 0000000..23b69b2 --- /dev/null +++ b/src/domain/eip712/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './errors'; +export * from './repository'; diff --git a/src/domain/eip712/repository.ts b/src/domain/eip712/repository.ts new file mode 100644 index 0000000..2a79804 --- /dev/null +++ b/src/domain/eip712/repository.ts @@ -0,0 +1,47 @@ +import { PrivateKey } from 'casper-js-sdk'; +import { + IEIP712Digest, + IEIP712DisplayModel, + IEIP712SignResult, + IEIP712SignTypedDataOptions, + IEIP712TypedData, +} from './entities'; + +export interface IEIP712SignDigestParams { + privateKey: PrivateKey; + digest: string; +} + +export interface IEIP712SignTypedDataParams { + typedData: IEIP712TypedData; + privateKey: PrivateKey; + options?: IEIP712SignTypedDataOptions; +} + +export interface IEIP712RecoverSignerParams { + typedData: IEIP712TypedData; + /** Raw 65-byte recoverable secp256k1 signature (r‖s‖v). Not the wallet's `02`-prefixed format. */ + signature: Uint8Array; + options?: IEIP712SignTypedDataOptions; +} + +export interface IEIP712VerifySignatureParams { + digest: string; + /** Raw 65-byte recoverable secp256k1 signature (r‖s‖v). */ + signature: Uint8Array; + /** 0x-prefixed 20-byte Ethereum-style address (as returned by recoverSigner). */ + expectedAddress: string; +} + +/** + * All methods are synchronous: EIP-712 operations are pure CPU work (validation, hashing, signing) + * with no network or I/O, unlike the async HTTP-backed repositories in this package. + */ +export interface IEIP712Repository { + computeDigest(typedData: IEIP712TypedData, options?: IEIP712SignTypedDataOptions): IEIP712Digest; + buildDisplayModel(typedData: IEIP712TypedData): IEIP712DisplayModel; + signDigest(params: IEIP712SignDigestParams): IEIP712SignResult; + signTypedData(params: IEIP712SignTypedDataParams): IEIP712SignResult; + recoverSigner(params: IEIP712RecoverSignerParams): string; + verifySignature(params: IEIP712VerifySignatureParams): boolean; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 1269013..c9058a1 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -9,3 +9,4 @@ export * from './accountInfo'; export * from './appEvents'; export * from './tx-signature-request'; export * from './contractPackage'; +export * from './eip712'; diff --git a/src/setup.integration.test.ts b/src/setup.integration.test.ts index 0169c1a..062d85d 100644 --- a/src/setup.integration.test.ts +++ b/src/setup.integration.test.ts @@ -1,7 +1,7 @@ import { setupRepositories } from './setup'; describe('setupRepositories (integration)', () => { - it('wires all 9 repositories', () => { + it('wires all 10 repositories', () => { const repos = setupRepositories(); expect(repos.accountInfoRepository).toBeDefined(); @@ -13,6 +13,7 @@ describe('setupRepositories (integration)', () => { expect(repos.appEventsRepository).toBeDefined(); expect(repos.txSignatureRequestRepository).toBeDefined(); expect(repos.contractPackageRepository).toBeDefined(); + expect(repos.eip712Repository).toBeDefined(); }); it('honors debug flag (logger is wired)', () => { diff --git a/src/setup.ts b/src/setup.ts index 8ede33b..86bf08e 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -9,6 +9,7 @@ import { AppEventsRepository, TxSignatureRequestRepository, ContractPackageRepository, + EIP712Repository, } from './data/repositories'; import { Logger } from './utils'; import { @@ -75,6 +76,7 @@ export const setupRepositories = ({ grpcUrl, httpAuthorizationHeader, ); + const eip712Repository = new EIP712Repository(); return { accountInfoRepository, @@ -86,5 +88,6 @@ export const setupRepositories = ({ appEventsRepository, txSignatureRequestRepository, contractPackageRepository, + eip712Repository, }; }; diff --git a/src/utils/eip712/digest.test.ts b/src/utils/eip712/digest.test.ts new file mode 100644 index 0000000..88e01e1 --- /dev/null +++ b/src/utils/eip712/digest.test.ts @@ -0,0 +1,45 @@ +import { PermitTypes, buildDomain } from '@casper-ecosystem/casper-eip-712'; +import { computeTypedDataDigest } from './digest'; + +const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); +const MESSAGE = { + owner: '0x00' + '02'.repeat(32), + spender: '0x01' + '03'.repeat(32), + value: 1000n, + nonce: 0n, + deadline: 1999999999n, +}; +const TYPED_DATA = { domain: DOMAIN, types: PermitTypes, primaryType: 'Permit', message: MESSAGE }; + +describe('computeTypedDataDigest', () => { + it('produces the stable EIP-712 digest', () => { + const { digest, resolvedDomainTypes, hashArtifacts } = computeTypedDataDigest(TYPED_DATA); + expect(digest).toBe('0x545a8088d6365ada6ef282f4d5979c655cd428b4115cbf65f561bcd65767a98c'); + expect(resolvedDomainTypes.map(f => f.name)).toEqual([ + 'name', + 'version', + 'chain_name', + 'contract_package_hash', + ]); + expect(hashArtifacts).toBeUndefined(); + }); + + it('populates all six hash artifacts when requested', () => { + const { hashArtifacts } = computeTypedDataDigest(TYPED_DATA, { returnHashArtifacts: true }); + expect(hashArtifacts).toEqual({ + domainTypeString: + 'EIP712Domain(string name,string version,string chain_name,bytes32 contract_package_hash)', + domain: DOMAIN, + domainSeparator: '0xeed1f627794e98725ac59414659cc8799539a6fccbde06b561962f14442a5abc', + structHash: '0xbd663ef94cb1c6c341a21af953bdf2ce0d48ddde160c42bebe1e08bc36cd0674', + canonicalTypeString: + 'Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)', + typeHash: '0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9', + }); + }); + + it('rejects unknown message fields only when asked', () => { + const withExtra = { ...TYPED_DATA, message: { ...MESSAGE, extra: 1n } }; + expect(() => computeTypedDataDigest(withExtra, { rejectUnknownFields: true })).toThrow(); + }); +}); diff --git a/src/utils/eip712/digest.ts b/src/utils/eip712/digest.ts new file mode 100644 index 0000000..53f408f --- /dev/null +++ b/src/utils/eip712/digest.ts @@ -0,0 +1,58 @@ +import { + buildCanonicalTypeString, + buildDomainTypeString, + computeTypeHash, + EIP712Domain, + hashDomainSeparator, + hashStruct, + hashTypedData, + toHex, +} from '@casper-ecosystem/casper-eip-712'; +import { IEIP712Digest, IEIP712SignTypedDataOptions, IEIP712TypedData } from '../../domain'; +import { + resolveDomainTypes, + validateNoUnknownMessageFields, + validatePrimaryType, + validateTypedDataFieldTypes, +} from './validation'; + +export function computeTypedDataDigest( + typedData: IEIP712TypedData, + options: IEIP712SignTypedDataOptions = {}, +): IEIP712Digest { + const { domain, types, primaryType, message } = typedData; + const { domainTypes, returnHashArtifacts, rejectUnknownFields } = options; + + validatePrimaryType(types, primaryType); + const resolvedDomainTypes = resolveDomainTypes(domain, types, domainTypes); + validateTypedDataFieldTypes(types); + if (rejectUnknownFields) { + validateNoUnknownMessageFields(types, primaryType, message); + } + + const eip712Domain = domain as EIP712Domain; + + const digest = toHex( + hashTypedData(eip712Domain, types, primaryType, message, { + domainTypes: resolvedDomainTypes, + }), + ); + + if (!returnHashArtifacts) { + return { digest, resolvedDomainTypes }; + } + + const canonicalTypeString = buildCanonicalTypeString(primaryType, types); + return { + digest, + resolvedDomainTypes, + hashArtifacts: { + domainTypeString: buildDomainTypeString(eip712Domain, resolvedDomainTypes), + domain, + domainSeparator: toHex(hashDomainSeparator(eip712Domain, resolvedDomainTypes)), + structHash: toHex(hashStruct(primaryType, types, message)), + canonicalTypeString, + typeHash: toHex(computeTypeHash(canonicalTypeString)), + }, + }; +} diff --git a/src/utils/eip712/displayModel.test.ts b/src/utils/eip712/displayModel.test.ts new file mode 100644 index 0000000..b371cd5 --- /dev/null +++ b/src/utils/eip712/displayModel.test.ts @@ -0,0 +1,59 @@ +import { buildTypedDataDisplayModel, keyToLabel } from './displayModel'; + +const FULL_ADDR = '0x00' + '02'.repeat(32); +const PKG_HASH = '0x' + '01'.repeat(32); + +describe('keyToLabel', () => { + it('maps contract_package_hash to "Package Hash"', () => { + expect(keyToLabel('contract_package_hash')).toBe('Package Hash'); + }); + it('title-cases snake_case otherwise', () => { + expect(keyToLabel('chain_name')).toBe('Chain Name'); + }); +}); + +describe('buildTypedDataDisplayModel', () => { + const typedData = { + domain: { chain_name: 'casper', contract_package_hash: PKG_HASH }, + types: { + EIP712Domain: [ + { name: 'chain_name', type: 'string' }, + { name: 'contract_package_hash', type: 'bytes32' }, + ], + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'tag', type: 'bytes32' }, + { name: 'value', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + message: { owner: FULL_ADDR, tag: PKG_HASH, value: '1000' }, + }; + + const model = buildTypedDataDisplayModel(typedData); + + it('domain: contract_package_hash is an address row, bytes32 domain field is NOT', () => { + const pkg = model.domainRows.find(r => r.label === 'Package Hash')!; + expect(pkg.isAddress).toBe(true); + expect(pkg.copyValue).toBe(PKG_HASH); + expect(pkg.displayValue).not.toBe(PKG_HASH); // shortened + const chain = model.domainRows.find(r => r.label === 'Chain Name')!; + expect(chain.isAddress).toBe(false); + expect(chain.copyValue).toBeNull(); + }); + + it('message: both address and bytes32 are address rows', () => { + const owner = model.messageRows.find(r => r.label === 'Owner')!; + const tag = model.messageRows.find(r => r.label === 'Tag')!; + const value = model.messageRows.find(r => r.label === 'Value')!; + expect(owner.isAddress).toBe(true); + expect(tag.isAddress).toBe(true); // bytes32 in message IS address-formatted + expect(value.isAddress).toBe(false); + expect(owner.copyValue).toBe(FULL_ADDR); + expect(value.displayValue).toBe('1000'); + }); + + it('carries primaryType', () => { + expect(model.primaryType).toBe('Permit'); + }); +}); diff --git a/src/utils/eip712/displayModel.ts b/src/utils/eip712/displayModel.ts new file mode 100644 index 0000000..655bdae --- /dev/null +++ b/src/utils/eip712/displayModel.ts @@ -0,0 +1,55 @@ +import { formatAddress } from '../address'; +import { + IEIP712DisplayModel, + IEIP712DisplayRow, + IEIP712TypedData, + IEIP712Types, +} from '../../domain'; + +function getTypeForField(fieldName: string, types: IEIP712Types, typeName: string): string { + const fields = types[typeName]; + if (!fields) { + return ''; + } + return fields.find(f => f.name === fieldName)?.type ?? ''; +} + +export function keyToLabel(key: string): string { + switch (key) { + case 'contract_package_hash': + return 'Package Hash'; + default: + return key.replace(/_/g, ' ').replace(/\b\w/g, match => match.toUpperCase()); + } +} + +function toRow(key: string, type: string, value: string, isAddress: boolean): IEIP712DisplayRow { + return { + label: keyToLabel(key), + value, + displayValue: isAddress ? formatAddress(value, 'short') : value, + isAddress, + copyValue: isAddress ? value : null, + type, + }; +} + +export function buildTypedDataDisplayModel(typedData: IEIP712TypedData): IEIP712DisplayModel { + const { domain, types, primaryType, message } = typedData; + + const domainRows = Object.entries(domain).map(([key, val]) => { + const type = getTypeForField(key, types, 'EIP712Domain'); + // Domain asymmetry: address rows are `address` OR the contract_package_hash key only. + const isAddress = type === 'address' || key === 'contract_package_hash'; + return toRow(key, type, String(val), isAddress); + }); + + const messageFields = types[primaryType] ?? []; + const messageRows = messageFields.map(field => { + // Message asymmetry: address rows are `address` OR `bytes32`. + const isAddress = field.type === 'address' || field.type === 'bytes32'; + return toRow(field.name, field.type, String(message[field.name]), isAddress); + }); + + return { domainRows, messageRows, primaryType }; +} diff --git a/src/utils/eip712/index.ts b/src/utils/eip712/index.ts new file mode 100644 index 0000000..e043693 --- /dev/null +++ b/src/utils/eip712/index.ts @@ -0,0 +1,5 @@ +export * from './validation'; +export * from './digest'; +export * from './displayModel'; +export * from './sign'; +export * from './recover'; diff --git a/src/utils/eip712/recover.test.ts b/src/utils/eip712/recover.test.ts new file mode 100644 index 0000000..575a175 --- /dev/null +++ b/src/utils/eip712/recover.test.ts @@ -0,0 +1,52 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { PermitTypes, buildDomain, fromHex } from '@casper-ecosystem/casper-eip-712'; +import { computeTypedDataDigest } from './digest'; +import { + CASPER_DOMAIN_TYPES, + PermitTypes as ReexportedPermitTypes, + recoverTypedDataSignerAddress, + verifyTypedDataSignature, +} from './recover'; + +const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); +const MESSAGE = { + owner: '0x00' + '02'.repeat(32), + spender: '0x01' + '03'.repeat(32), + value: 1000n, + nonce: 0n, + deadline: 1999999999n, +}; +const TYPED_DATA = { domain: DOMAIN, types: PermitTypes, primaryType: 'Permit', message: MESSAGE }; + +function recoverableSig(): Uint8Array { + const { digest } = computeTypedDataDigest(TYPED_DATA); + const sig = secp256k1.sign(fromHex(digest), fromHex('11'.repeat(32))); + const out = new Uint8Array(65); + out.set(sig.toCompactRawBytes(), 0); + out[64] = sig.recovery; + return out; +} + +describe('recover/verify', () => { + it('recovers the expected Ethereum-style address', () => { + const address = recoverTypedDataSignerAddress({ + typedData: TYPED_DATA, + signature: recoverableSig(), + }); + expect(address).toBe('0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a'); + }); + + it('verifies a signature against the recovered address', () => { + const sig = recoverableSig(); + const { digest } = computeTypedDataDigest(TYPED_DATA); + const address = recoverTypedDataSignerAddress({ typedData: TYPED_DATA, signature: sig }); + expect(verifyTypedDataSignature({ digest, signature: sig, expectedAddress: address })).toBe( + true, + ); + }); + + it('re-exports lib helpers for consumers', () => { + expect(ReexportedPermitTypes).toBeDefined(); + expect(CASPER_DOMAIN_TYPES.map(f => f.name)).toContain('contract_package_hash'); + }); +}); diff --git a/src/utils/eip712/recover.ts b/src/utils/eip712/recover.ts new file mode 100644 index 0000000..808d710 --- /dev/null +++ b/src/utils/eip712/recover.ts @@ -0,0 +1,50 @@ +import { + ApprovalTypes, + buildDomain, + CASPER_DOMAIN_TYPES, + EIP712Domain, + fromHex, + PermitTypes, + recoverTypedDataSigner, + toHex, + TransferTypes, + verifySignature as libVerifySignature, +} from '@casper-ecosystem/casper-eip-712'; +import { IEIP712RecoverSignerParams, IEIP712VerifySignatureParams } from '../../domain'; +import { resolveDomainTypes } from './validation'; + +/** + * NOTE: these wrappers are secp256k1 / Ethereum-style. `signature` must be the raw 65-byte + * recoverable signature (r‖s‖v) and `recoverTypedDataSignerAddress` returns a 0x-prefixed 20-byte + * Ethereum address. They do NOT accept the wallet's `02`-prefixed 64-byte wire format and do NOT + * apply to ed25519 (use the native key's verifySignature for ed25519). + */ +export function recoverTypedDataSignerAddress({ + typedData, + signature, + options, +}: IEIP712RecoverSignerParams): string { + const { domain, types, primaryType, message } = typedData; + const resolvedDomainTypes = resolveDomainTypes(domain, types, options?.domainTypes); + const addressBytes = recoverTypedDataSigner( + domain as EIP712Domain, + types, + primaryType, + message, + signature, + { domainTypes: resolvedDomainTypes }, + ); + return toHex(addressBytes); +} + +/** secp256k1 only; `signature` is the raw 65-byte recoverable sig (r‖s‖v) over the keccak256 digest. */ +export function verifyTypedDataSignature({ + digest, + signature, + expectedAddress, +}: IEIP712VerifySignatureParams): boolean { + return libVerifySignature(fromHex(digest), signature, expectedAddress); +} + +// Re-export lib helpers so consumers build typed data without importing the lib directly. +export { CASPER_DOMAIN_TYPES, buildDomain, PermitTypes, ApprovalTypes, TransferTypes }; diff --git a/src/utils/eip712/sign.test.ts b/src/utils/eip712/sign.test.ts new file mode 100644 index 0000000..dd7518a --- /dev/null +++ b/src/utils/eip712/sign.test.ts @@ -0,0 +1,74 @@ +import { PrivateKey, KeyAlgorithm } from 'casper-js-sdk'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { PermitTypes, buildDomain, fromHex } from '@casper-ecosystem/casper-eip-712'; +import { computeTypedDataDigest } from './digest'; +import { signTypedData, signTypedDataDigestWithKey, signTypedDataWithRawKey } from './sign'; + +const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); +const MESSAGE = { + owner: '0x00' + '02'.repeat(32), + spender: '0x01' + '03'.repeat(32), + value: 1000n, + nonce: 0n, + deadline: 1999999999n, +}; +const TYPED_DATA = { domain: DOMAIN, types: PermitTypes, primaryType: 'Permit', message: MESSAGE }; +const SCALAR_HEX = '11'.repeat(32); +const EXPECTED_DIGEST = '0x545a8088d6365ada6ef282f4d5979c655cd428b4115cbf65f561bcd65767a98c'; +const EXPECTED_SECP_SIG = + '02acebd9d168c39959011754c86c70ebb2dbf122c8f9f22e915c2300a1f256757876728402a1ba9cb5de947ac1d148a3552321741abc43f2e67fbd3a94a0e0a5cb'; + +describe('signTypedDataDigestWithKey (secp256k1)', () => { + it('matches the reference secp256k1.sign(sha256(digest)) byte-for-byte (no double hash)', () => { + const key = PrivateKey.fromHex(SCALAR_HEX, KeyAlgorithm.SECP256K1); + const result = signTypedDataDigestWithKey(key, EXPECTED_DIGEST); + + const reference = + '02' + + Buffer.from( + secp256k1.sign(sha256(fromHex(EXPECTED_DIGEST)), fromHex(SCALAR_HEX)).toCompactRawBytes(), + ).toString('hex'); + + expect(result.signature).toBe(reference); + expect(result.signature).toBe(EXPECTED_SECP_SIG); + expect(result.digest).toBe(EXPECTED_DIGEST); + expect(result.publicKey).toBe(key.publicKey.toHex()); + }); +}); + +describe('signTypedDataWithRawKey (ed25519)', () => { + it('produces a 01-prefixed signature that round-trips via the native key', () => { + const scalar = fromHex('22'.repeat(32)); + const result = signTypedDataWithRawKey(scalar, 'ed25519', EXPECTED_DIGEST); + + expect(result.signature.startsWith('01')).toBe(true); + + const key = PrivateKey.fromHex('22'.repeat(32), KeyAlgorithm.ED25519); + const sigBytes = Uint8Array.from(Buffer.from(result.signature, 'hex')); + expect(key.publicKey.verifySignature(fromHex(EXPECTED_DIGEST), sigBytes)).toBe(true); + }); +}); + +describe('signTypedData (end-to-end)', () => { + it('computes digest then signs, attaching artifacts when requested', () => { + const key = PrivateKey.fromHex(SCALAR_HEX, KeyAlgorithm.SECP256K1); + const result = signTypedData(TYPED_DATA, key, { returnHashArtifacts: true }); + + expect(result.digest).toBe(EXPECTED_DIGEST); + expect(result.signature).toBe(EXPECTED_SECP_SIG); + expect(result.hashArtifacts?.typeHash).toBe( + '0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9', + ); + + const { digest } = computeTypedDataDigest(TYPED_DATA); + expect(result.digest).toBe(digest); + }); + + it('omits hashArtifacts when not requested', () => { + const key = PrivateKey.fromHex(SCALAR_HEX, KeyAlgorithm.SECP256K1); + const result = signTypedData(TYPED_DATA, key); + expect(result.hashArtifacts).toBeUndefined(); + expect(result.signature).toBe(EXPECTED_SECP_SIG); + }); +}); diff --git a/src/utils/eip712/sign.ts b/src/utils/eip712/sign.ts new file mode 100644 index 0000000..cc9500f --- /dev/null +++ b/src/utils/eip712/sign.ts @@ -0,0 +1,55 @@ +import { KeyAlgorithm, PrivateKey } from 'casper-js-sdk'; +import { fromHex } from '@casper-ecosystem/casper-eip-712'; +import { + EIP712SignatureScheme, + IEIP712SignResult, + IEIP712SignTypedDataOptions, + IEIP712TypedData, +} from '../../domain'; +import { convertBytesToHex } from '../crypto'; +import { computeTypedDataDigest } from './digest'; + +/** + * Sign an EIP-712 digest with a casper-js-sdk PrivateKey. + * + * NO DOUBLE HASH (load-bearing): casper-js-sdk's secp256k1 PrivateKey prehashes the message with + * SHA-256 internally before signing. Passing the RAW EIP-712 digest therefore reproduces the + * reference `secp256k1.sign(sha256(digest))` wire format byte-for-byte. Do NOT sha256 the digest + * here. ed25519 signs the digest directly (no prehash). `signAndAddAlgorithmBytes` prepends the + * algorithm byte: '01' for ed25519, '02' for secp256k1. + * + * Both schemes share the SAME `signAndAddAlgorithmBytes` call below — only casper-js-sdk's internal + * secp256k1 path applies a SHA-256 prehash; ed25519 receives and signs the digest bytes directly + * with NO internal prehash. + */ +export function signTypedDataDigestWithKey( + privateKey: PrivateKey, + digestHex: string, +): IEIP712SignResult { + const signatureBytes = privateKey.signAndAddAlgorithmBytes(fromHex(digestHex)); + return { + signature: convertBytesToHex(signatureBytes), + digest: digestHex, + publicKey: privateKey.publicKey.toHex(), + }; +} + +export function signTypedDataWithRawKey( + scalarBytes: Uint8Array, + scheme: EIP712SignatureScheme, + digestHex: string, +): IEIP712SignResult { + const algorithm = scheme === 'ed25519' ? KeyAlgorithm.ED25519 : KeyAlgorithm.SECP256K1; + const privateKey = PrivateKey.fromHex(convertBytesToHex(scalarBytes), algorithm); + return signTypedDataDigestWithKey(privateKey, digestHex); +} + +export function signTypedData( + typedData: IEIP712TypedData, + privateKey: PrivateKey, + options: IEIP712SignTypedDataOptions = {}, +): IEIP712SignResult { + const { digest, hashArtifacts } = computeTypedDataDigest(typedData, options); + const result = signTypedDataDigestWithKey(privateKey, digest); + return hashArtifacts ? { ...result, hashArtifacts } : result; +} diff --git a/src/utils/eip712/validation.test.ts b/src/utils/eip712/validation.test.ts new file mode 100644 index 0000000..ce04ea3 --- /dev/null +++ b/src/utils/eip712/validation.test.ts @@ -0,0 +1,110 @@ +import fc from 'fast-check'; +import { CASPER_DOMAIN_TYPES, PermitTypes, buildDomain } from '@casper-ecosystem/casper-eip-712'; +import { EIP712Error, IEIP712Types, SignTypedDataErrorCodes } from '../../domain'; +import { + resolveDomainTypes, + validateNoUnknownMessageFields, + validatePrimaryType, + validateTypedDataFieldTypes, +} from './validation'; + +const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); + +describe('validatePrimaryType', () => { + it('throws INVALID_PARAMS when primaryType is missing', () => { + let caught: unknown; + try { + validatePrimaryType(PermitTypes, 'Nope'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(EIP712Error); + expect((caught as EIP712Error).errorCode).toBe(SignTypedDataErrorCodes.INVALID_PARAMS); + }); + + it('passes when primaryType exists', () => { + expect(() => validatePrimaryType(PermitTypes, 'Permit')).not.toThrow(); + }); +}); + +describe('resolveDomainTypes (4-step priority)', () => { + it('1) prefers types.EIP712Domain', () => { + const custom = [{ name: 'name', type: 'string' }]; + const res = resolveDomainTypes( + DOMAIN, + { EIP712Domain: custom } as unknown as IEIP712Types, + undefined, + ); + expect(res).toBe(custom); + }); + + it('2) falls back to options.domainTypes', () => { + const opt = [{ name: 'name', type: 'string' }]; + const res = resolveDomainTypes(DOMAIN, {} as unknown as IEIP712Types, opt); + expect(res).toBe(opt); + }); + + it('3) derives from CASPER_DOMAIN_TYPES for known fields', () => { + const res = resolveDomainTypes(DOMAIN, {} as unknown as IEIP712Types, undefined); + expect(res.map(f => f.name)).toEqual( + CASPER_DOMAIN_TYPES.filter(f => DOMAIN[f.name] != null).map(f => f.name), + ); + }); + + it('4) throws DOMAIN_TYPES_REQUIRED for unknown domain field', () => { + let caught: unknown; + try { + resolveDomainTypes({ unexpected: 'x' }, {} as unknown as IEIP712Types, undefined); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(EIP712Error); + expect((caught as EIP712Error).errorCode).toBe(SignTypedDataErrorCodes.DOMAIN_TYPES_REQUIRED); + }); +}); + +describe('validateTypedDataFieldTypes', () => { + it('rejects array types', () => { + expect(() => validateTypedDataFieldTypes({ T: [{ name: 'a', type: 'uint256[]' }] })).toThrow( + EIP712Error, + ); + }); + + it('allows bytes32', () => { + expect(() => + validateTypedDataFieldTypes({ T: [{ name: 'a', type: 'bytes32' }] }), + ).not.toThrow(); + }); + + it('rejects every bytes1..bytes31 (property)', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 31 }), n => { + try { + validateTypedDataFieldTypes({ T: [{ name: 'a', type: `bytes${n}` }] }); + return false; + } catch (e) { + return (e as EIP712Error).errorCode === SignTypedDataErrorCodes.UNSUPPORTED_TYPE; + } + }), + ); + }); +}); + +describe('validateNoUnknownMessageFields', () => { + it('throws INVALID_PARAMS for unknown message key', () => { + let caught: unknown; + try { + validateNoUnknownMessageFields(PermitTypes, 'Permit', { surprise: 1 }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(EIP712Error); + expect((caught as EIP712Error).errorCode).toBe(SignTypedDataErrorCodes.INVALID_PARAMS); + }); + + it('passes when all keys are known', () => { + expect(() => + validateNoUnknownMessageFields(PermitTypes, 'Permit', { owner: 'x' }), + ).not.toThrow(); + }); +}); diff --git a/src/utils/eip712/validation.ts b/src/utils/eip712/validation.ts new file mode 100644 index 0000000..75cf2ac --- /dev/null +++ b/src/utils/eip712/validation.ts @@ -0,0 +1,83 @@ +import { CASPER_DOMAIN_TYPES } from '@casper-ecosystem/casper-eip-712'; +import { EIP712Error, IEIP712Field, IEIP712Types, SignTypedDataErrorCodes } from '../../domain'; + +const ARRAY_PATTERN = /\[/; // matches any array syntax: [], [N] +const BYTES_1_TO_31_PATTERN = /^bytes([1-9]|[12][0-9]|3[01])$/; + +export function validatePrimaryType(types: IEIP712Types, primaryType: string): void { + if (!types[primaryType]) { + throw new EIP712Error( + new Error(`Primary type "${primaryType}" not found in type definitions`), + 'validateTypedData', + SignTypedDataErrorCodes.INVALID_PARAMS, + ); + } +} + +export function resolveDomainTypes( + domain: Record, + types: IEIP712Types, + domainTypesOption?: IEIP712Field[], +): IEIP712Field[] { + if (types.EIP712Domain) { + return types.EIP712Domain; + } + if (domainTypesOption) { + return domainTypesOption; + } + + const casperFieldNames = new Set(CASPER_DOMAIN_TYPES.map(f => f.name)); + for (const key of Object.keys(domain)) { + if (!casperFieldNames.has(key)) { + throw new EIP712Error( + new Error( + `Domain field "${key}" is not in CASPER_DOMAIN_TYPES. Provide options.domainTypes or include EIP712Domain in types.`, + ), + 'resolveDomainTypes', + SignTypedDataErrorCodes.DOMAIN_TYPES_REQUIRED, + ); + } + } + + return CASPER_DOMAIN_TYPES.filter(f => domain[f.name] != null); +} + +export function validateTypedDataFieldTypes(types: IEIP712Types): void { + for (const [typeName, fields] of Object.entries(types)) { + for (const field of fields) { + if (ARRAY_PATTERN.test(field.type)) { + throw new EIP712Error( + new Error(`Array types are not supported: "${field.type}" in ${typeName}.${field.name}`), + 'validateTypedData', + SignTypedDataErrorCodes.UNSUPPORTED_TYPE, + ); + } + if (BYTES_1_TO_31_PATTERN.test(field.type)) { + throw new EIP712Error( + new Error( + `bytes1..bytes31 types are not supported: "${field.type}" in ${typeName}.${field.name}`, + ), + 'validateTypedData', + SignTypedDataErrorCodes.UNSUPPORTED_TYPE, + ); + } + } + } +} + +export function validateNoUnknownMessageFields( + types: IEIP712Types, + primaryType: string, + message: Record, +): void { + const knownFields = new Set(types[primaryType].map(f => f.name)); + for (const key of Object.keys(message)) { + if (!knownFields.has(key)) { + throw new EIP712Error( + new Error(`Unknown field "${key}" in message not defined in type "${primaryType}"`), + 'validateTypedData', + SignTypedDataErrorCodes.INVALID_PARAMS, + ); + } + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 5523b75..07014f9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,3 +6,4 @@ export * from './casperSdk'; export * from './deploy'; export * from './logger'; export * from './signatureRequest'; +export * from './eip712'; diff --git a/yarn.lock b/yarn.lock index 95ac116..edfe177 100644 --- a/yarn.lock +++ b/yarn.lock @@ -395,6 +395,16 @@ __metadata: languageName: node linkType: hard +"@casper-ecosystem/casper-eip-712@npm:1.2.1": + version: 1.2.1 + resolution: "@casper-ecosystem/casper-eip-712@npm:1.2.1" + dependencies: + "@noble/curves": "npm:^1.8.0" + "@noble/hashes": "npm:^1.7.0" + checksum: 10c0/6770847d34b57f1df0092f4dd174eb8c89153bbb534a3cbb9eb1619ce514f69a610c7f50e193fecead9b3bcf215d86c28d7268fc64bb54517a40d754284666d6 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -907,7 +917,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.1.0, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.1.0, @noble/curves@npm:^1.8.0, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -923,7 +933,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.8.0, @noble/hashes@npm:~1.8.0": +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.7.0, @noble/hashes@npm:^1.8.0, @noble/hashes@npm:~1.8.0": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10c0/06a0b52c81a6fa7f04d67762e08b2c476a00285858150caeaaff4037356dd5e119f45b2a530f638b77a5eeca013168ec1b655db41bae3236cb2e9d511484fc77 @@ -1306,6 +1316,7 @@ __metadata: version: 0.0.0-use.local resolution: "CasperWalletCore@workspace:." dependencies: + "@casper-ecosystem/casper-eip-712": "npm:1.2.1" "@eslint/eslintrc": "npm:^3.3.5" "@eslint/js": "npm:^9.39.4" "@noble/hashes": "npm:^1.8.0"