From 724da0214e45755ae3cb7795b19ac8d2022a269a Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 13:13:43 +0300 Subject: [PATCH 01/16] feat(eip712): add EIP-712 domain module scaffolding - Added EIP-712 entities, errors, and repository interfaces to support typed data handling, signing, and verification - Integrated `@casper-ecosystem/casper-eip-712` dependency for EIP-712 compatibility - Included unit tests for EIP-712 error handling --- package.json | 1 + src/domain/eip712/entities.ts | 60 ++++++++++++++++++++++++++++++++ src/domain/eip712/errors.test.ts | 23 ++++++++++++ src/domain/eip712/errors.ts | 44 +++++++++++++++++++++++ src/domain/eip712/index.ts | 3 ++ src/domain/eip712/repository.ts | 47 +++++++++++++++++++++++++ yarn.lock | 15 ++++++-- 7 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/domain/eip712/entities.ts create mode 100644 src/domain/eip712/errors.test.ts create mode 100644 src/domain/eip712/errors.ts create mode 100644 src/domain/eip712/index.ts create mode 100644 src/domain/eip712/repository.ts 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/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..99e803b --- /dev/null +++ b/src/domain/eip712/errors.ts @@ -0,0 +1,44 @@ +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' + | '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/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" From 1136c062ee556990eb24cc6edf80b64aec5040a2 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 13:38:36 +0300 Subject: [PATCH 02/16] feat(eip712): implement validation utilities with comprehensive tests - Added `validatePrimaryType`, `resolveDomainTypes`, `validateTypedDataFieldTypes`, and `validateNoUnknownMessageFields` functions for EIP-712 typed data validation - Included unit tests to cover validation logic and edge cases --- src/domain/index.ts | 1 + src/utils/eip712/validation.test.ts | 110 ++++++++++++++++++++++++++++ src/utils/eip712/validation.ts | 83 +++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 src/utils/eip712/validation.test.ts create mode 100644 src/utils/eip712/validation.ts 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/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, + ); + } + } +} From 4ece675f84e6103b6ab212806d846491df1c8df0 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 14:09:05 +0300 Subject: [PATCH 03/16] feat(eip712): add `computeTypedDataDigest` utility with tests - Implemented `computeTypedDataDigest` to generate EIP-712 digests and optional hash artifacts - Included comprehensive unit tests for digest stability, artifact generation, and strict field validation --- src/utils/eip712/digest.test.ts | 45 +++++++++++++++++++++++++ src/utils/eip712/digest.ts | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/utils/eip712/digest.test.ts create mode 100644 src/utils/eip712/digest.ts 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)), + }, + }; +} From 03faa9f4e84ad35640c0281cf44ba93c1a3d676a Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 14:40:53 +0300 Subject: [PATCH 04/16] feat(eip712): add `buildTypedDataDisplayModel` utility with tests - Implemented `buildTypedDataDisplayModel` to generate structured display models for EIP-712 typed data - Added `keyToLabel` function to map keys to user-friendly labels - Included comprehensive unit tests for domain and message row formatting, type handling, and address detection --- src/utils/eip712/displayModel.test.ts | 59 +++++++++++++++++++++++++++ src/utils/eip712/displayModel.ts | 55 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/utils/eip712/displayModel.test.ts create mode 100644 src/utils/eip712/displayModel.ts 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 }; +} From 66d829cf5024b72899343395f76244cdab7822f8 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 14:55:56 +0300 Subject: [PATCH 05/16] feat(eip712): add typed data signing utilities with tests - Implemented `signTypedData`, `signTypedDataDigestWithKey`, and `signTypedDataWithRawKey` for EIP-712 signing - Added unit tests to verify signature correctness, digest computation, and hash artifact handling - Ensured compatibility with `ed25519` and `secp256k1` signing algorithms --- src/utils/eip712/sign.test.ts | 74 +++++++++++++++++++++++++++++++++++ src/utils/eip712/sign.ts | 55 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/utils/eip712/sign.test.ts create mode 100644 src/utils/eip712/sign.ts 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; +} From 80cebd6da084da2d8133ae4b37d161c034a5fb52 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 15:41:46 +0300 Subject: [PATCH 06/16] feat(eip712): add typed data signature recovery and verification utilities with tests - Implemented `recoverTypedDataSignerAddress` and `verifyTypedDataSignature` for Ethereum-style signature recovery and validation - Re-exported EIP-712 domain helpers (`CASPER_DOMAIN_TYPES`, `buildDomain`, `PermitTypes`, etc.) - Added unit tests to validate signature recovery, verification, and helper re-exports --- src/utils/eip712/recover.test.ts | 52 ++++++++++++++++++++++++++++++++ src/utils/eip712/recover.ts | 50 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/utils/eip712/recover.test.ts create mode 100644 src/utils/eip712/recover.ts 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 }; From 09297e4ebffc27582cb01ed8567c49521f26e936 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 15:43:39 +0300 Subject: [PATCH 07/16] refactor(utils): re-export EIP-712 utilities through main utils module --- src/utils/eip712/index.ts | 5 +++++ src/utils/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 src/utils/eip712/index.ts 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/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'; From cfc31d63c79e93e4718976bbf0d2c970dd8c66da Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 15:56:38 +0300 Subject: [PATCH 08/16] feat(eip712): implement EIP-712 repository with unit tests - Added `EIP712Repository` class for typed data handling, digest computation, signing, and verification. - Implemented methods for building display models, signing typed data, and recovering/verifying signatures. - Included comprehensive unit tests to validate repository behavior and error handling. --- src/data/repositories/eip712/eip712.test.ts | 65 +++++++++++++++++++++ src/data/repositories/eip712/index.ts | 64 ++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/data/repositories/eip712/eip712.test.ts create mode 100644 src/data/repositories/eip712/index.ts 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..1da7b51 --- /dev/null +++ b/src/data/repositories/eip712/index.ts @@ -0,0 +1,64 @@ +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. + */ +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, 'signTypedData'); + } + } + + 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); + } +} From 11c3da89315f5567d04853441951c1e8f7acf22a Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 15:59:40 +0300 Subject: [PATCH 09/16] feat(setup): integrate EIP-712 repository into system setup --- src/data/repositories/index.ts | 1 + src/setup.ts | 3 +++ 2 files changed, 4 insertions(+) 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/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, }; }; From 3d45004c64eb90cf3215e82d19bd2993555cc353 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 4 Jun 2026 16:17:41 +0300 Subject: [PATCH 10/16] docs(eip712): clarify error-handling behavior in method documentation --- src/data/repositories/eip712/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/data/repositories/eip712/index.ts b/src/data/repositories/eip712/index.ts index 1da7b51..0a990f3 100644 --- a/src/data/repositories/eip712/index.ts +++ b/src/data/repositories/eip712/index.ts @@ -24,6 +24,10 @@ import { /** * 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 { From feae98e8757012c776a31e489fa1892dbfa61665 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Mon, 8 Jun 2026 10:08:06 +0300 Subject: [PATCH 11/16] fix(eip712): update error type and integration test for repository setup - Adjusted `EIP712Error` instantiation to use `signDigest` as the error context. - Updated integration test to include the new `eip712Repository` in repository wiring validation. --- src/data/repositories/eip712/index.ts | 2 +- src/domain/eip712/errors.ts | 1 + src/setup.integration.test.ts | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data/repositories/eip712/index.ts b/src/data/repositories/eip712/index.ts index 0a990f3..fdde509 100644 --- a/src/data/repositories/eip712/index.ts +++ b/src/data/repositories/eip712/index.ts @@ -46,7 +46,7 @@ export class EIP712Repository implements IEIP712Repository { try { return signTypedDataDigestWithKey(privateKey, digest); } catch (e) { - throw isEIP712Error(e) ? e : new EIP712Error(e, 'signTypedData'); + throw isEIP712Error(e) ? e : new EIP712Error(e, 'signDigest'); } } diff --git a/src/domain/eip712/errors.ts b/src/domain/eip712/errors.ts index 99e803b..e7b4719 100644 --- a/src/domain/eip712/errors.ts +++ b/src/domain/eip712/errors.ts @@ -12,6 +12,7 @@ export type EIP712ErrorType = | 'validateTypedData' | 'resolveDomainTypes' | 'computeDigest' + | 'signDigest' | 'signTypedData'; export type IEIP712Error = IDomainError & { 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)', () => { From e547e9a8de485ecc4f660a846f66117c0fe092f3 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Mon, 8 Jun 2026 12:01:20 +0300 Subject: [PATCH 12/16] feat(eip712): enhance `buildTypedDataDisplayModel` with type-based presentation and enrichment support - Introduced `getPresentationForType` to classify types into `account`, `hash`, `number`, or `string`. - Updated display model rows to include additional metadata: `presentation`, `accountInfo`, and `contractPackage`. - Added `IEIP712DisplayEnrichment` for optional domain/message row enrichment. - Expanded unit tests to validate type classification, presentation logic, and enrichment capabilities. --- src/domain/eip712/entities.ts | 29 +++++++++- src/utils/eip712/displayModel.test.ts | 83 ++++++++++++++++++++++----- src/utils/eip712/displayModel.ts | 79 +++++++++++++++++++------ 3 files changed, 159 insertions(+), 32 deletions(-) diff --git a/src/domain/eip712/entities.ts b/src/domain/eip712/entities.ts index 6c16941..d92f0a5 100644 --- a/src/domain/eip712/entities.ts +++ b/src/domain/eip712/entities.ts @@ -1,4 +1,8 @@ import { Maybe } from '../../typings'; +import { AccountKeyType } from '../deploys'; +import { CasperNetwork, IEntity } from '../common'; +import { IAccountInfo } from '../accountInfo'; +import { IContractPackage } from '../contractPackage'; export interface IEIP712Field { name: string; @@ -35,13 +39,17 @@ export interface IEIP712Digest { hashArtifacts?: IEIP712HashArtifacts; } +export type EIP712FieldPresentation = 'hash' | 'number' | 'account' | 'string'; + export interface IEIP712DisplayRow { label: string; value: string; displayValue: string; - isAddress: boolean; - copyValue: Maybe; + presentation: EIP712FieldPresentation; type: string; + copyValue: Maybe; + accountInfo: Maybe; + contractPackage: Maybe; } export interface IEIP712DisplayModel { @@ -58,3 +66,20 @@ export interface IEIP712SignResult { publicKey: string; hashArtifacts?: IEIP712HashArtifacts; } + +export interface IEIP712SignatureRequest extends IEntity { + readonly signingKey: string; + readonly signingKeyType: AccountKeyType; + readonly signingAccountInfo: Maybe; + + readonly network: Maybe; + readonly chainName: string; + readonly primaryType: string; + + readonly domainRows: IEIP712DisplayRow[]; + readonly messageRows: IEIP712DisplayRow[]; + + readonly digest: string; + readonly hashArtifacts?: IEIP712HashArtifacts; + readonly rawJson: string; +} diff --git a/src/utils/eip712/displayModel.test.ts b/src/utils/eip712/displayModel.test.ts index b371cd5..c53d22d 100644 --- a/src/utils/eip712/displayModel.test.ts +++ b/src/utils/eip712/displayModel.test.ts @@ -1,8 +1,30 @@ -import { buildTypedDataDisplayModel, keyToLabel } from './displayModel'; +import { buildTypedDataDisplayModel, getPresentationForType, keyToLabel } from './displayModel'; -const FULL_ADDR = '0x00' + '02'.repeat(32); +const FULL_ADDR = 'a'.repeat(64); // 64 hex chars, no 0x prefix — like a Casper account hash const PKG_HASH = '0x' + '01'.repeat(32); +describe('getPresentationForType', () => { + it('classifies bytes1..bytes32 as hash', () => { + expect(getPresentationForType('bytes1')).toBe('hash'); + expect(getPresentationForType('bytes32')).toBe('hash'); + }); + it('classifies uint*/int* as number', () => { + expect(getPresentationForType('uint8')).toBe('number'); + expect(getPresentationForType('uint256')).toBe('number'); + expect(getPresentationForType('int256')).toBe('number'); + expect(getPresentationForType('uint')).toBe('number'); + expect(getPresentationForType('int')).toBe('number'); + }); + it('classifies address as account', () => { + expect(getPresentationForType('address')).toBe('account'); + }); + it('classifies bool and unknown as string', () => { + expect(getPresentationForType('bool')).toBe('string'); + expect(getPresentationForType('string')).toBe('string'); + expect(getPresentationForType('bytes33')).toBe('string'); + }); +}); + describe('keyToLabel', () => { it('maps contract_package_hash to "Package Hash"', () => { expect(keyToLabel('contract_package_hash')).toBe('Package Hash'); @@ -30,30 +52,63 @@ describe('buildTypedDataDisplayModel', () => { message: { owner: FULL_ADDR, tag: PKG_HASH, value: '1000' }, }; - const model = buildTypedDataDisplayModel(typedData); + it('classifies presentation and leaves enrichment null on the sync path', () => { + 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.presentation).toBe('hash'); expect(pkg.copyValue).toBe(PKG_HASH); expect(pkg.displayValue).not.toBe(PKG_HASH); // shortened + expect(pkg.accountInfo).toBeNull(); + expect(pkg.contractPackage).toBeNull(); + const chain = model.domainRows.find(r => r.label === 'Chain Name')!; - expect(chain.isAddress).toBe(false); + expect(chain.presentation).toBe('string'); 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.presentation).toBe('account'); expect(owner.copyValue).toBe(FULL_ADDR); + expect(owner.accountInfo).toBeNull(); + expect(value.presentation).toBe('number'); expect(value.displayValue).toBe('1000'); + expect(model.primaryType).toBe('Permit'); + + const tag = model.messageRows.find(r => r.label === 'Tag')!; + expect(tag.presentation).toBe('hash'); // bytes32 in message → hash, not account + expect(tag.copyValue).toBe(PKG_HASH); // still shortened + copyable }); - it('carries primaryType', () => { - expect(model.primaryType).toBe('Permit'); + it('attaches enrichment through the callback', () => { + const accountInfo = { + id: 'x', + publicKey: '02pub', + accountHash: FULL_ADDR, + name: 'Alice', + brandingLogo: null, + csprName: null, + explorerLink: null, + }; + const contractPackage = { + id: 'c', + latestVersionContractTypeId: 1, + contractPackageHash: PKG_HASH, + name: 'MyToken', + iconUrl: null, + symbol: 'MTK', + decimals: 9, + }; + + const model = buildTypedDataDisplayModel(typedData, { + resolveAccountInfo: value => (value === FULL_ADDR ? accountInfo : null), + contractPackage, + }); + + expect(model.messageRows.find(r => r.label === 'Owner')!.accountInfo).toEqual(accountInfo); + expect(model.domainRows.find(r => r.label === 'Package Hash')!.contractPackage).toEqual( + contractPackage, + ); + expect(model.messageRows.find(r => r.label === 'Value')!.accountInfo).toBeNull(); }); }); diff --git a/src/utils/eip712/displayModel.ts b/src/utils/eip712/displayModel.ts index 655bdae..550072a 100644 --- a/src/utils/eip712/displayModel.ts +++ b/src/utils/eip712/displayModel.ts @@ -1,10 +1,30 @@ import { formatAddress } from '../address'; import { + EIP712FieldPresentation, + IAccountInfo, + IContractPackage, IEIP712DisplayModel, IEIP712DisplayRow, IEIP712TypedData, IEIP712Types, } from '../../domain'; +import { Maybe } from '../../typings'; + +const BYTES_TYPE_REGEX = /^bytes(?:[1-9]|[12]\d|3[0-2])$/; +const NUMBER_TYPE_REGEX = /^u?int\d*$/; + +export function getPresentationForType(type: string): EIP712FieldPresentation { + if (type === 'address') { + return 'account'; + } + if (BYTES_TYPE_REGEX.test(type)) { + return 'hash'; + } + if (NUMBER_TYPE_REGEX.test(type)) { + return 'number'; + } + return 'string'; +} function getTypeForField(fieldName: string, types: IEIP712Types, typeName: string): string { const fields = types[typeName]; @@ -23,33 +43,60 @@ export function keyToLabel(key: string): string { } } -function toRow(key: string, type: string, value: string, isAddress: boolean): IEIP712DisplayRow { +/** + * Optional enrichment for display rows. `utils` stays free of `data`-layer imports: the data layer + * passes a closure (over `getAccountInfoFromMap`) and the already-fetched contract package. + */ +export interface IEIP712DisplayEnrichment { + resolveAccountInfo?: (rawValue: string) => Maybe; + contractPackage?: Maybe; +} + +function toRow( + key: string, + type: string, + value: string, + enrichment: IEIP712DisplayEnrichment, + section: 'domain' | 'message', +): IEIP712DisplayRow { + const presentation = getPresentationForType(type); + const isHashLike = presentation === 'hash' || presentation === 'account'; + + const accountInfo = + presentation === 'account' && enrichment.resolveAccountInfo + ? enrichment.resolveAccountInfo(value) + : null; + const contractPackage = + section === 'domain' && key === 'contract_package_hash' + ? enrichment.contractPackage ?? null + : null; + return { label: keyToLabel(key), value, - displayValue: isAddress ? formatAddress(value, 'short') : value, - isAddress, - copyValue: isAddress ? value : null, + displayValue: isHashLike ? formatAddress(value, 'short') : value, + presentation, type, + copyValue: isHashLike ? value : null, + accountInfo, + contractPackage, }; } -export function buildTypedDataDisplayModel(typedData: IEIP712TypedData): IEIP712DisplayModel { +export function buildTypedDataDisplayModel( + typedData: IEIP712TypedData, + enrichment: IEIP712DisplayEnrichment = {}, +): 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 domainRows = Object.entries(domain).map(([key, val]) => + toRow(key, getTypeForField(key, types, 'EIP712Domain'), String(val), enrichment, 'domain'), + ); 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); - }); + const messageRows = messageFields.map(field => + toRow(field.name, field.type, String(message[field.name]), enrichment, 'message'), + ); return { domainRows, messageRows, primaryType }; } From 09f11a37e8fd32853da1c0d5e3bd30adca1c40a8 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Mon, 8 Jun 2026 12:17:52 +0300 Subject: [PATCH 13/16] feat(eip712): add `getAccountHashesFromTypedData` utility with unit tests - Implemented `getAccountHashesFromTypedData` to extract and deduplicate account hashes from typed data, including signing key-derived hashes. - Added comprehensive test cases for address-based field collection, deduplication, and edge cases handling. - Updated exports to include the new utility in the `eip712` module. --- src/data/dto/eip712/common.test.ts | 58 ++++++++++++++++++++++++++++++ src/data/dto/eip712/common.ts | 30 ++++++++++++++++ src/data/dto/eip712/index.ts | 1 + src/data/dto/index.ts | 1 + 4 files changed, 90 insertions(+) create mode 100644 src/data/dto/eip712/common.test.ts create mode 100644 src/data/dto/eip712/common.ts create mode 100644 src/data/dto/eip712/index.ts diff --git a/src/data/dto/eip712/common.test.ts b/src/data/dto/eip712/common.test.ts new file mode 100644 index 0000000..a7403cf --- /dev/null +++ b/src/data/dto/eip712/common.test.ts @@ -0,0 +1,58 @@ +import { getAccountHashesFromTypedData } from './common'; +import { getAccountHashFromPublicKey } from '../../../utils'; + +const OWNER = 'a'.repeat(64); +const SPENDER = 'b'.repeat(64); +const SIGNING_PK = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; + +const typedData = { + domain: { chain_name: 'casper', contract_package_hash: '0x' + '01'.repeat(32) }, + types: { + EIP712Domain: [ + { name: 'chain_name', type: 'string' }, + { name: 'contract_package_hash', type: 'bytes32' }, + ], + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + message: { owner: OWNER, spender: SPENDER, value: '1000' }, +}; + +describe('getAccountHashesFromTypedData', () => { + it('collects address-typed values plus the signing-key account hash, deduped', () => { + const hashes = getAccountHashesFromTypedData(typedData, SIGNING_PK); + + expect(hashes).toContain(OWNER); + expect(hashes).toContain(SPENDER); + expect(hashes).toContain(getAccountHashFromPublicKey(SIGNING_PK)); + // non-address fields are ignored: value/contract_package_hash/chain_name absent + expect(hashes).not.toContain('1000'); + // deduped + expect(new Set(hashes).size).toBe(hashes.length); + }); + + it('returns only the signing-key hash when there are no address fields', () => { + const noAddr = { + ...typedData, + types: { ...typedData.types, Permit: [{ name: 'value', type: 'uint256' }] }, + message: { value: '1' }, + }; + const hashes = getAccountHashesFromTypedData(noAddr, SIGNING_PK); + expect(hashes).toEqual([getAccountHashFromPublicKey(SIGNING_PK)]); + }); + + it('ignores an address field whose value is missing/non-string', () => { + const missingValue = { + ...typedData, + types: { ...typedData.types, Permit: [{ name: 'owner', type: 'address' }] }, + message: {}, // owner declared as address but absent + }; + const hashes = getAccountHashesFromTypedData(missingValue, SIGNING_PK); + expect(hashes).toEqual([getAccountHashFromPublicKey(SIGNING_PK)]); + expect(hashes).not.toContain('undefined'); + }); +}); diff --git a/src/data/dto/eip712/common.ts b/src/data/dto/eip712/common.ts new file mode 100644 index 0000000..7115ed3 --- /dev/null +++ b/src/data/dto/eip712/common.ts @@ -0,0 +1,30 @@ +import { IEIP712Field, IEIP712TypedData } from '../../../domain'; +import { Maybe } from '../../../typings'; +import { getHashByType } from '../common'; + +/** + * Account hashes to resolve for a typed-data request: every `address`-typed field value across the + * domain and the primary-type message (as account hashes), plus the signing key (as a public key). + * Deduplicated; null/empty results dropped. + */ +export const getAccountHashesFromTypedData = ( + typedData: IEIP712TypedData, + signingPublicKeyHex: string, +): string[] => { + const hashes: Array> = []; + + const collect = (fields: IEIP712Field[] | undefined, source: Record) => { + (fields ?? []).forEach(({ name, type }) => { + const value = source[name]; + if (type === 'address' && typeof value === 'string' && value) { + hashes.push(getHashByType(value, 'accountHash')); + } + }); + }; + + collect(typedData.types.EIP712Domain, typedData.domain); + collect(typedData.types[typedData.primaryType], typedData.message); + hashes.push(getHashByType(signingPublicKeyHex, 'publicKey')); + + return Array.from(new Set(hashes.filter((h): h is string => Boolean(h)))); +}; diff --git a/src/data/dto/eip712/index.ts b/src/data/dto/eip712/index.ts new file mode 100644 index 0000000..d0b9323 --- /dev/null +++ b/src/data/dto/eip712/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/src/data/dto/index.ts b/src/data/dto/index.ts index 3caa464..9b2155f 100644 --- a/src/data/dto/index.ts +++ b/src/data/dto/index.ts @@ -7,3 +7,4 @@ export * from './accountInfo'; export * from './appEvents'; export * from './txSignatureRequest'; export * from './contractPackage'; +export * from './eip712'; From c116e818d7352b791bf906aa3653e0317af674e1 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Mon, 8 Jun 2026 12:33:26 +0300 Subject: [PATCH 14/16] feat(eip712): add `EIP712SignatureRequestDto` with test coverage - Introduced `EIP712SignatureRequestDto` for encapsulating EIP-712 signature request details. - Implemented support for account info resolution, contract package enrichment, and hash artifacts forwarding. - Added comprehensive unit tests to validate field mappings, enrichment logic, and fallback behavior. - Updated exports to include the new DTO in the `eip712` module. --- .../eip712/EIP712SignatureRequestDto.test.ts | 99 +++++++++++++++++++ .../dto/eip712/EIP712SignatureRequestDto.ts | 70 +++++++++++++ src/data/dto/eip712/index.ts | 1 + 3 files changed, 170 insertions(+) create mode 100644 src/data/dto/eip712/EIP712SignatureRequestDto.test.ts create mode 100644 src/data/dto/eip712/EIP712SignatureRequestDto.ts diff --git a/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts new file mode 100644 index 0000000..fecac5d --- /dev/null +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts @@ -0,0 +1,99 @@ +import { EIP712SignatureRequestDto } from './EIP712SignatureRequestDto'; + +const OWNER = 'a'.repeat(64); +const PKG_HASH = '0x' + '01'.repeat(32); +const SIGNING_PK = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; + +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: 'value', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + message: { owner: OWNER, value: '1000' }, +}; + +const ownerAccountInfo = { + id: 'a', + publicKey: '', + accountHash: OWNER, + name: 'Alice', + brandingLogo: null, + csprName: null, + explorerLink: null, +}; + +const contractPackage = { + id: 'c', + latestVersionContractTypeId: 1, + contractPackageHash: PKG_HASH, + name: 'MyToken', + iconUrl: null, + symbol: 'MTK', + decimals: 9, +}; + +describe('EIP712SignatureRequestDto', () => { + const dto = new EIP712SignatureRequestDto({ + typedData, + signingPublicKeyHex: SIGNING_PK, + network: 'mainnet', + digest: '0xdigest', + accountInfoMap: { [OWNER]: ownerAccountInfo }, + contractPackage, + }); + + it('enriches address rows with account info', () => { + expect(dto.messageRows.find(r => r.label === 'Owner')!.accountInfo).toEqual(ownerAccountInfo); + }); + + it('enriches the contract_package_hash row with the contract package', () => { + expect(dto.domainRows.find(r => r.label === 'Package Hash')!.contractPackage).toEqual( + contractPackage, + ); + }); + + it('carries scalar fields', () => { + expect(dto.network).toBe('mainnet'); + expect(dto.chainName).toBe('casper'); + expect(dto.primaryType).toBe('Permit'); + expect(dto.digest).toBe('0xdigest'); + expect(dto.id).toBe('0xdigest'); + expect(JSON.parse(dto.rawJson)).toEqual(typedData); + expect(dto.hashArtifacts).toBeUndefined(); + }); + + it('forwards hashArtifacts when provided', () => { + const artifacts = { + domainTypeString: 'EIP712Domain(...)', + domain: typedData.domain, + domainSeparator: '0xsep', + structHash: '0xstruct', + canonicalTypeString: 'Permit(...)', + typeHash: '0xtype', + }; + const withArtifacts = new EIP712SignatureRequestDto({ + typedData, + signingPublicKeyHex: SIGNING_PK, + network: 'mainnet', + digest: '0xdigest', + hashArtifacts: artifacts, + accountInfoMap: {}, + contractPackage: null, + }); + expect(withArtifacts.hashArtifacts).toEqual(artifacts); + }); + + it('falls back to the raw signing key when no account info is found', () => { + expect(dto.signingKey).toBe(SIGNING_PK); + expect(dto.signingKeyType).toBe('publicKey'); + expect(dto.signingAccountInfo).toBeNull(); + }); +}); diff --git a/src/data/dto/eip712/EIP712SignatureRequestDto.ts b/src/data/dto/eip712/EIP712SignatureRequestDto.ts new file mode 100644 index 0000000..13909e0 --- /dev/null +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.ts @@ -0,0 +1,70 @@ +import { Maybe } from '../../../typings'; +import { + AccountKeyType, + CasperNetwork, + IAccountInfo, + IContractPackage, + IEIP712DisplayRow, + IEIP712HashArtifacts, + IEIP712SignatureRequest, + IEIP712TypedData, +} from '../../../domain'; +import { buildTypedDataDisplayModel } from '../../../utils'; +import { deriveKeyType, getAccountInfoFromMap } from '../common'; + +export interface IEIP712SignatureRequestDtoProps { + typedData: IEIP712TypedData; + signingPublicKeyHex: string; + network: Maybe; + digest: string; + hashArtifacts?: IEIP712HashArtifacts; + accountInfoMap: Record; + contractPackage: Maybe; +} + +export class EIP712SignatureRequestDto implements IEIP712SignatureRequest { + readonly id: string; + readonly signingKey: string; + readonly signingKeyType: AccountKeyType; + readonly signingAccountInfo: Maybe; + readonly network: Maybe; + readonly chainName: string; + readonly primaryType: string; + readonly domainRows: IEIP712DisplayRow[]; + readonly messageRows: IEIP712DisplayRow[]; + readonly digest: string; + readonly hashArtifacts?: IEIP712HashArtifacts; + readonly rawJson: string; + + constructor({ + typedData, + signingPublicKeyHex, + network, + digest, + hashArtifacts, + accountInfoMap, + contractPackage, + }: IEIP712SignatureRequestDtoProps) { + const { domainRows, messageRows } = buildTypedDataDisplayModel(typedData, { + resolveAccountInfo: value => getAccountInfoFromMap(accountInfoMap, value, 'accountHash'), + contractPackage, + }); + + const signingKeyType = deriveKeyType(signingPublicKeyHex); + // getAccountInfoFromMap yields runtime `undefined` for a missing key; normalize to null for Maybe<>. + this.signingAccountInfo = + getAccountInfoFromMap(accountInfoMap, signingPublicKeyHex, signingKeyType) ?? null; + this.signingKey = this.signingAccountInfo?.publicKey || signingPublicKeyHex; + this.signingKeyType = this.signingAccountInfo?.publicKey ? 'publicKey' : signingKeyType; + + this.network = network; + this.chainName = String(typedData.domain.chain_name ?? ''); + this.primaryType = typedData.primaryType; + this.domainRows = domainRows; + this.messageRows = messageRows; + this.digest = digest; + this.hashArtifacts = hashArtifacts; + this.rawJson = JSON.stringify(typedData); + this.id = digest; + } +} diff --git a/src/data/dto/eip712/index.ts b/src/data/dto/eip712/index.ts index d0b9323..98668a7 100644 --- a/src/data/dto/eip712/index.ts +++ b/src/data/dto/eip712/index.ts @@ -1 +1,2 @@ export * from './common'; +export * from './EIP712SignatureRequestDto'; From 58a72ac30c395759612e427b119e0c6da707a6d0 Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Tue, 9 Jun 2026 13:00:05 +0300 Subject: [PATCH 15/16] feat(eip712): add async prepareSignatureRequest with account/contract enrichment Adds EIP712Repository.prepareSignatureRequest, which returns a fully populated IEIP712SignatureRequest: fields classified by presentation type, address fields resolved to account info, contract_package_hash resolved to contract package, and chain_name mapped to CasperNetwork. Enrichment is best-effort (each lookup isolated); only invalid typed data throws EIP712Error. Wires the repository constructor in setup.ts. Co-Authored-By: Claude Opus 4.8 Signed-off-by: ost-ptk --- .../eip712/EIP712SignatureRequestDto.test.ts | 13 +++ .../dto/eip712/EIP712SignatureRequestDto.ts | 14 +++- src/data/dto/eip712/common.test.ts | 27 +++++- src/data/dto/eip712/common.ts | 21 ++++- src/data/repositories/eip712/eip712.test.ts | 82 ++++++++++++++++++- src/data/repositories/eip712/index.ts | 78 +++++++++++++++++- src/domain/eip712/repository.ts | 20 ++++- src/setup.ts | 2 +- src/utils/eip712/displayModel.test.ts | 2 +- src/utils/eip712/displayModel.ts | 3 +- 10 files changed, 246 insertions(+), 16 deletions(-) diff --git a/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts index fecac5d..5428ac3 100644 --- a/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts @@ -96,4 +96,17 @@ describe('EIP712SignatureRequestDto', () => { expect(dto.signingKeyType).toBe('publicKey'); expect(dto.signingAccountInfo).toBeNull(); }); + + it('serializes bigint message values in rawJson', () => { + const bigintData = { ...typedData, message: { owner: OWNER, value: 1000n } }; + const d = new EIP712SignatureRequestDto({ + typedData: bigintData, + signingPublicKeyHex: SIGNING_PK, + network: 'mainnet', + digest: '0xd', + accountInfoMap: {}, + contractPackage: null, + }); + expect(JSON.parse(d.rawJson).message.value).toBe('1000'); + }); }); diff --git a/src/data/dto/eip712/EIP712SignatureRequestDto.ts b/src/data/dto/eip712/EIP712SignatureRequestDto.ts index 13909e0..237b1fa 100644 --- a/src/data/dto/eip712/EIP712SignatureRequestDto.ts +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.ts @@ -11,6 +11,7 @@ import { } from '../../../domain'; import { buildTypedDataDisplayModel } from '../../../utils'; import { deriveKeyType, getAccountInfoFromMap } from '../common'; +import { resolveEip712AddressToAccountHash } from './common'; export interface IEIP712SignatureRequestDtoProps { typedData: IEIP712TypedData; @@ -46,7 +47,14 @@ export class EIP712SignatureRequestDto implements IEIP712SignatureRequest { contractPackage, }: IEIP712SignatureRequestDtoProps) { const { domainRows, messageRows } = buildTypedDataDisplayModel(typedData, { - resolveAccountInfo: value => getAccountInfoFromMap(accountInfoMap, value, 'accountHash'), + resolveAccountInfo: value => { + // address values may be a Casper public key or account hash — resolve to account hash first, + // then look up by it so the key matches what getAccountHashesFromTypedData fetched. + const accountHash = resolveEip712AddressToAccountHash(value); + return accountHash + ? (getAccountInfoFromMap(accountInfoMap, accountHash, 'accountHash') ?? null) + : null; + }, contractPackage, }); @@ -64,7 +72,9 @@ export class EIP712SignatureRequestDto implements IEIP712SignatureRequest { this.messageRows = messageRows; this.digest = digest; this.hashArtifacts = hashArtifacts; - this.rawJson = JSON.stringify(typedData); + this.rawJson = JSON.stringify(typedData, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ); this.id = digest; } } diff --git a/src/data/dto/eip712/common.test.ts b/src/data/dto/eip712/common.test.ts index a7403cf..8bd74c3 100644 --- a/src/data/dto/eip712/common.test.ts +++ b/src/data/dto/eip712/common.test.ts @@ -1,4 +1,8 @@ -import { getAccountHashesFromTypedData } from './common'; +import { + getAccountHashesFromTypedData, + resolveEip712AddressToAccountHash, + stripHexPrefix, +} from './common'; import { getAccountHashFromPublicKey } from '../../../utils'; const OWNER = 'a'.repeat(64); @@ -56,3 +60,24 @@ describe('getAccountHashesFromTypedData', () => { expect(hashes).not.toContain('undefined'); }); }); + +describe('stripHexPrefix', () => { + it('removes a leading 0x only', () => { + expect(stripHexPrefix('0xabc')).toBe('abc'); + expect(stripHexPrefix('abc')).toBe('abc'); + }); +}); + +describe('resolveEip712AddressToAccountHash', () => { + it('strips 0x and resolves a public key to its account hash', () => { + const expected = getAccountHashFromPublicKey(SIGNING_PK); + expect(resolveEip712AddressToAccountHash(SIGNING_PK)).toBe(expected); + expect(resolveEip712AddressToAccountHash('0x' + SIGNING_PK)).toBe(expected); + }); + + it('returns a 64-hex account-hash value unchanged (minus 0x)', () => { + const acct = 'c'.repeat(64); + expect(resolveEip712AddressToAccountHash(acct)).toBe(acct); + expect(resolveEip712AddressToAccountHash('0x' + acct)).toBe(acct); + }); +}); diff --git a/src/data/dto/eip712/common.ts b/src/data/dto/eip712/common.ts index 7115ed3..71eb41f 100644 --- a/src/data/dto/eip712/common.ts +++ b/src/data/dto/eip712/common.ts @@ -2,9 +2,26 @@ import { IEIP712Field, IEIP712TypedData } from '../../../domain'; import { Maybe } from '../../../typings'; import { getHashByType } from '../common'; +/** Strip an optional `0x` prefix. */ +export const stripHexPrefix = (value: string): string => + value.startsWith('0x') ? value.slice(2) : value; + +/** + * Resolve an EIP-712 `address` field value to a Casper account hash. The value may be a Casper public + * key (01/02-prefixed, 66/68 hex) or an account hash (64 hex), with an optional `0x` prefix. Returns + * null when it cannot be resolved. + */ +export const resolveEip712AddressToAccountHash = (rawValue: string): Maybe => { + const value = stripHexPrefix(String(rawValue)); + const isPublicKey = + (value.startsWith('01') && value.length === 66) || + (value.startsWith('02') && value.length === 68); + return getHashByType(value, isPublicKey ? 'publicKey' : 'accountHash'); +}; + /** * Account hashes to resolve for a typed-data request: every `address`-typed field value across the - * domain and the primary-type message (as account hashes), plus the signing key (as a public key). + * domain and the primary-type message (resolved to account hashes), plus the signing key. * Deduplicated; null/empty results dropped. */ export const getAccountHashesFromTypedData = ( @@ -17,7 +34,7 @@ export const getAccountHashesFromTypedData = ( (fields ?? []).forEach(({ name, type }) => { const value = source[name]; if (type === 'address' && typeof value === 'string' && value) { - hashes.push(getHashByType(value, 'accountHash')); + hashes.push(resolveEip712AddressToAccountHash(value)); } }); }; diff --git a/src/data/repositories/eip712/eip712.test.ts b/src/data/repositories/eip712/eip712.test.ts index f587e39..7d1390d 100644 --- a/src/data/repositories/eip712/eip712.test.ts +++ b/src/data/repositories/eip712/eip712.test.ts @@ -1,7 +1,10 @@ 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'; +import { AccountInfoRepository } from '../accountInfo'; +import { ContractPackageRepository } from '../contractPackage'; +import { createMockHttpProvider } from '../../../__test-utils__'; +import { CasperWalletApiByNetworkUrl, EIP712Error, isEIP712Error } from '../../../domain'; const DOMAIN = buildDomain('CasperSwap', '1', 'casper', '0x' + '01'.repeat(32)); const MESSAGE = { @@ -15,7 +18,18 @@ const TYPED_DATA = { domain: DOMAIN, types: PermitTypes, primaryType: 'Permit', const EXPECTED_DIGEST = '0x545a8088d6365ada6ef282f4d5979c655cd428b4115cbf65f561bcd65767a98c'; describe('EIP712Repository', () => { - const repo = new EIP712Repository(); + const buildRepo = () => { + const http = createMockHttpProvider(); + const accountInfoRepository = new AccountInfoRepository(http, CasperWalletApiByNetworkUrl); + const contractPackageRepository = new ContractPackageRepository( + http, + CasperWalletApiByNetworkUrl, + ); + const repo = new EIP712Repository(accountInfoRepository, contractPackageRepository); + return { repo, http, accountInfoRepository, contractPackageRepository }; + }; + + const repo = buildRepo().repo; it('computeDigest delegates to the digest util', () => { expect(repo.computeDigest(TYPED_DATA).digest).toBe(EXPECTED_DIGEST); @@ -62,4 +76,68 @@ describe('EIP712Repository', () => { expect(isEIP712Error(caught)).toBe(true); expect((caught as EIP712Error).type).toBe('computeDigest'); }); + + const SIGNING_PK = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; + + it('prepareSignatureRequest builds an enriched request', async () => { + const { repo: r, accountInfoRepository, contractPackageRepository } = buildRepo(); + jest.spyOn(accountInfoRepository, 'getAccountsInfo').mockResolvedValue({}); + jest.spyOn(contractPackageRepository, 'getContractPackage').mockResolvedValue(null); + + const req = await r.prepareSignatureRequest({ + typedData: TYPED_DATA, + signingPublicKeyHex: SIGNING_PK, + }); + + expect(req.digest).toBe(EXPECTED_DIGEST); + expect(req.primaryType).toBe('Permit'); + expect(req.network).toBe('mainnet'); + expect(req.messageRows).toHaveLength(5); + }); + + it('still builds when enrichment lookups throw', async () => { + const { repo: r, accountInfoRepository, contractPackageRepository } = buildRepo(); + jest.spyOn(accountInfoRepository, 'getAccountsInfo').mockRejectedValue(new Error('network')); + jest.spyOn(contractPackageRepository, 'getContractPackage').mockRejectedValue(new Error('x')); + + const req = await r.prepareSignatureRequest({ + typedData: TYPED_DATA, + signingPublicKeyHex: SIGNING_PK, + }); + + expect(req.digest).toBe(EXPECTED_DIGEST); + expect(req.messageRows.every(row => row.accountInfo === null)).toBe(true); + expect(req.domainRows.find(row => row.label === 'Package Hash')!.contractPackage).toBeNull(); + }); + + it('skips lookups when the network is unmappable and none is provided', async () => { + const { repo: r, accountInfoRepository } = buildRepo(); + const spy = jest.spyOn(accountInfoRepository, 'getAccountsInfo').mockResolvedValue({}); + const offNetwork = { + ...TYPED_DATA, + domain: { ...TYPED_DATA.domain, chain_name: 'unknown-chain' }, + }; + + const req = await r.prepareSignatureRequest({ + typedData: offNetwork, + signingPublicKeyHex: SIGNING_PK, + }); + + expect(spy).not.toHaveBeenCalled(); + expect(req.network).toBeNull(); + }); + + it('rejects with EIP712Error for invalid typed data', async () => { + const { repo: r } = buildRepo(); + let caught: unknown; + try { + await r.prepareSignatureRequest({ + typedData: { ...TYPED_DATA, primaryType: 'Missing' }, + signingPublicKeyHex: SIGNING_PK, + }); + } catch (e) { + caught = e; + } + expect(isEIP712Error(caught)).toBe(true); + }); }); diff --git a/src/data/repositories/eip712/index.ts b/src/data/repositories/eip712/index.ts index fdde509..3dc9245 100644 --- a/src/data/repositories/eip712/index.ts +++ b/src/data/repositories/eip712/index.ts @@ -1,35 +1,55 @@ import { EIP712Error, + IAccountInfo, + IAccountInfoRepository, + IContractPackage, + IContractPackageRepository, IEIP712Digest, IEIP712DisplayModel, IEIP712RecoverSignerParams, IEIP712Repository, + IEIP712SignatureRequest, IEIP712SignDigestParams, IEIP712SignResult, IEIP712SignTypedDataOptions, IEIP712SignTypedDataParams, IEIP712TypedData, IEIP712VerifySignatureParams, + IPrepareEIP712SignatureRequestParams, isEIP712Error, } from '../../../domain'; +import { Maybe } from '../../../typings'; import { buildTypedDataDisplayModel, computeTypedDataDigest, + getCasperNetworkByChainName, recoverTypedDataSignerAddress, signTypedData as signTypedDataUtil, signTypedDataDigestWithKey, verifyTypedDataSignature, } from '../../../utils'; +import { + EIP712SignatureRequestDto, + getAccountHashesFromTypedData, + stripHexPrefix, +} from '../../dto'; /** - * 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. + * `prepareSignatureRequest` is asynchronous: it enriches the typed data with account info and + * contract-package data over HTTP (best-effort — each lookup is isolated in its own try/catch, so a + * flaky API never breaks the request). The other methods are synchronous pure-CPU work. * * `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). + * `prepareSignatureRequest` surfaces digest/validation failures as {@link EIP712Error} (via + * `computeDigest`) and swallows enrichment-lookup failures (best-effort). `recoverSigner` and + * `verifySignature` surface raw library errors. */ export class EIP712Repository implements IEIP712Repository { + constructor( + private _accountInfoRepository: IAccountInfoRepository, + private _contractPackageRepository: IContractPackageRepository, + ) {} + computeDigest(typedData: IEIP712TypedData, options?: IEIP712SignTypedDataOptions): IEIP712Digest { try { return computeTypedDataDigest(typedData, options); @@ -65,4 +85,54 @@ export class EIP712Repository implements IEIP712Repository { verifySignature(params: IEIP712VerifySignatureParams): boolean { return verifyTypedDataSignature(params); } + + async prepareSignatureRequest({ + typedData, + signingPublicKeyHex, + network: networkParam, + options, + withProxyHeader = true, + }: IPrepareEIP712SignatureRequestParams): Promise { + const { digest, hashArtifacts } = this.computeDigest(typedData, options); + + const network = + getCasperNetworkByChainName(String(typedData.domain.chain_name ?? '')) ?? + networkParam ?? + null; + + let accountInfoMap: Record = {}; + let contractPackage: Maybe = null; + + if (network) { + try { + const accountHashes = getAccountHashesFromTypedData(typedData, signingPublicKeyHex); + accountInfoMap = await this._accountInfoRepository.getAccountsInfo({ + accountHashes, + network, + withProxyHeader, + }); + } catch {} + + try { + const contractPackageHash = typedData.domain.contract_package_hash; + if (contractPackageHash) { + contractPackage = await this._contractPackageRepository.getContractPackage({ + contractPackageHash: stripHexPrefix(String(contractPackageHash)), + network, + withProxyHeader, + }); + } + } catch {} + } + + return new EIP712SignatureRequestDto({ + typedData, + signingPublicKeyHex, + network, + digest, + hashArtifacts, + accountInfoMap, + contractPackage, + }); + } } diff --git a/src/domain/eip712/repository.ts b/src/domain/eip712/repository.ts index 2a79804..f2a644d 100644 --- a/src/domain/eip712/repository.ts +++ b/src/domain/eip712/repository.ts @@ -2,10 +2,12 @@ import { PrivateKey } from 'casper-js-sdk'; import { IEIP712Digest, IEIP712DisplayModel, + IEIP712SignatureRequest, IEIP712SignResult, IEIP712SignTypedDataOptions, IEIP712TypedData, } from './entities'; +import { CasperNetwork } from '../common'; export interface IEIP712SignDigestParams { privateKey: PrivateKey; @@ -33,9 +35,20 @@ export interface IEIP712VerifySignatureParams { expectedAddress: string; } +export interface IPrepareEIP712SignatureRequestParams { + typedData: IEIP712TypedData; + signingPublicKeyHex: string; + /** Fallback network when `domain.chain_name` cannot be mapped. */ + network?: CasperNetwork; + options?: IEIP712SignTypedDataOptions; + /** Default `true`. */ + withProxyHeader?: boolean; +} + /** - * 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. + * `prepareSignatureRequest` is asynchronous: it enriches the typed data with account info and + * contract-package data over HTTP. The remaining methods are synchronous — EIP-712 validation, + * hashing and signing are pure CPU work with no I/O. */ export interface IEIP712Repository { computeDigest(typedData: IEIP712TypedData, options?: IEIP712SignTypedDataOptions): IEIP712Digest; @@ -44,4 +57,7 @@ export interface IEIP712Repository { signTypedData(params: IEIP712SignTypedDataParams): IEIP712SignResult; recoverSigner(params: IEIP712RecoverSignerParams): string; verifySignature(params: IEIP712VerifySignatureParams): boolean; + prepareSignatureRequest( + params: IPrepareEIP712SignatureRequestParams, + ): Promise; } diff --git a/src/setup.ts b/src/setup.ts index 86bf08e..3611478 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -76,7 +76,7 @@ export const setupRepositories = ({ grpcUrl, httpAuthorizationHeader, ); - const eip712Repository = new EIP712Repository(); + const eip712Repository = new EIP712Repository(accountInfoRepository, contractPackageRepository); return { accountInfoRepository, diff --git a/src/utils/eip712/displayModel.test.ts b/src/utils/eip712/displayModel.test.ts index c53d22d..f1200d4 100644 --- a/src/utils/eip712/displayModel.test.ts +++ b/src/utils/eip712/displayModel.test.ts @@ -77,7 +77,7 @@ describe('buildTypedDataDisplayModel', () => { const tag = model.messageRows.find(r => r.label === 'Tag')!; expect(tag.presentation).toBe('hash'); // bytes32 in message → hash, not account - expect(tag.copyValue).toBe(PKG_HASH); // still shortened + copyable + expect(tag.copyValue).toBe(PKG_HASH); // still shortened + copyable }); it('attaches enrichment through the callback', () => { diff --git a/src/utils/eip712/displayModel.ts b/src/utils/eip712/displayModel.ts index 550072a..c8081d2 100644 --- a/src/utils/eip712/displayModel.ts +++ b/src/utils/eip712/displayModel.ts @@ -44,6 +44,7 @@ export function keyToLabel(key: string): string { } /** + * @internal * Optional enrichment for display rows. `utils` stays free of `data`-layer imports: the data layer * passes a closure (over `getAccountInfoFromMap`) and the already-fetched contract package. */ @@ -68,7 +69,7 @@ function toRow( : null; const contractPackage = section === 'domain' && key === 'contract_package_hash' - ? enrichment.contractPackage ?? null + ? (enrichment.contractPackage ?? null) : null; return { From 6fbac2f38bc8eb5da5f494d8ee1f044dedadba6c Mon Sep 17 00:00:00 2001 From: ost-ptk Date: Thu, 11 Jun 2026 13:24:04 +0300 Subject: [PATCH 16/16] feat(eip712): enforce hash presentation for `contract_package_hash` in domain rows - Updated `buildTypedDataDisplayModel` to force `hash` presentation for `contract_package_hash`, even if undeclared in `types.EIP712Domain` or specified as `string`. - Added test case to validate shortened display and full value copyability for undeclared `contract_package_hash`. --- src/utils/eip712/displayModel.test.ts | 17 +++++++++++++++++ src/utils/eip712/displayModel.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utils/eip712/displayModel.test.ts b/src/utils/eip712/displayModel.test.ts index f1200d4..e4f89d2 100644 --- a/src/utils/eip712/displayModel.test.ts +++ b/src/utils/eip712/displayModel.test.ts @@ -80,6 +80,23 @@ describe('buildTypedDataDisplayModel', () => { expect(tag.copyValue).toBe(PKG_HASH); // still shortened + copyable }); + it('forces hash presentation for an undeclared domain contract_package_hash', () => { + const undeclared = { + domain: { chain_name: 'casper', contract_package_hash: FULL_ADDR }, + types: { EIP712Domain: [], Permit: [{ name: 'value', type: 'uint256' }] }, + primaryType: 'Permit', + message: { value: '1' }, + }; + + const pkg = buildTypedDataDisplayModel(undeclared).domainRows.find( + r => r.label === 'Package Hash', + )!; + expect(pkg.type).toBe(''); // not declared in types.EIP712Domain + expect(pkg.presentation).toBe('hash'); + expect(pkg.copyValue).toBe(FULL_ADDR); // full value copyable + expect(pkg.displayValue).not.toBe(FULL_ADDR); // shortened + }); + it('attaches enrichment through the callback', () => { const accountInfo = { id: 'x', diff --git a/src/utils/eip712/displayModel.ts b/src/utils/eip712/displayModel.ts index c8081d2..2df10fe 100644 --- a/src/utils/eip712/displayModel.ts +++ b/src/utils/eip712/displayModel.ts @@ -60,7 +60,11 @@ function toRow( enrichment: IEIP712DisplayEnrichment, section: 'domain' | 'message', ): IEIP712DisplayRow { - const presentation = getPresentationForType(type); + // The domain `contract_package_hash` is semantically always a hash. Some payloads omit it from + // `types.EIP712Domain` (so `type` is '') or send it as `string`, which would otherwise classify it + // as 'string' and leave the value un-shortened and non-copyable. Force hash presentation by key. + const isContractPackageHash = section === 'domain' && key === 'contract_package_hash'; + const presentation = isContractPackageHash ? 'hash' : getPresentationForType(type); const isHashLike = presentation === 'hash' || presentation === 'account'; const accountInfo =