diff --git a/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts new file mode 100644 index 0000000..5428ac3 --- /dev/null +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.test.ts @@ -0,0 +1,112 @@ +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(); + }); + + 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 new file mode 100644 index 0000000..237b1fa --- /dev/null +++ b/src/data/dto/eip712/EIP712SignatureRequestDto.ts @@ -0,0 +1,80 @@ +import { Maybe } from '../../../typings'; +import { + AccountKeyType, + CasperNetwork, + IAccountInfo, + IContractPackage, + IEIP712DisplayRow, + IEIP712HashArtifacts, + IEIP712SignatureRequest, + IEIP712TypedData, +} from '../../../domain'; +import { buildTypedDataDisplayModel } from '../../../utils'; +import { deriveKeyType, getAccountInfoFromMap } from '../common'; +import { resolveEip712AddressToAccountHash } 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 => { + // 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, + }); + + 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, (_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 new file mode 100644 index 0000000..8bd74c3 --- /dev/null +++ b/src/data/dto/eip712/common.test.ts @@ -0,0 +1,83 @@ +import { + getAccountHashesFromTypedData, + resolveEip712AddressToAccountHash, + stripHexPrefix, +} 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'); + }); +}); + +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 new file mode 100644 index 0000000..71eb41f --- /dev/null +++ b/src/data/dto/eip712/common.ts @@ -0,0 +1,47 @@ +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 (resolved to account hashes), plus the signing 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(resolveEip712AddressToAccountHash(value)); + } + }); + }; + + 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..98668a7 --- /dev/null +++ b/src/data/dto/eip712/index.ts @@ -0,0 +1,2 @@ +export * from './common'; +export * from './EIP712SignatureRequestDto'; 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'; 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/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/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 b371cd5..e4f89d2 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,80 @@ 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('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', + 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..2df10fe 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,65 @@ export function keyToLabel(key: string): string { } } -function toRow(key: string, type: string, value: string, isAddress: boolean): IEIP712DisplayRow { +/** + * @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. + */ +export interface IEIP712DisplayEnrichment { + resolveAccountInfo?: (rawValue: string) => Maybe; + contractPackage?: Maybe; +} + +function toRow( + key: string, + type: string, + value: string, + enrichment: IEIP712DisplayEnrichment, + section: 'domain' | 'message', +): IEIP712DisplayRow { + // 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 = + 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 }; }