diff --git a/AGENTS.md b/AGENTS.md index 07fe881..606a61c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -403,6 +403,14 @@ decides what it can decrypt. Token metadata is resolved entirely via `ExternalDataProvider.resolveToken(chainId, address)`. There is no embedded token registry. When `resolveToken` is absent or returns `null`, the library emits a `UNKNOWN_TOKEN` warning and falls back to the raw value. +### Context-Dependent Constants (`metadata.maps`) + +A `params` value that is normally a constant (e.g. `token`, `nativeCurrencyAddress`, `threshold`, the `unit` scale) may instead be a **map reference** — a `{ map, keyPath }` object that resolves a per-context constant from a `metadata.maps` lookup table. This lets one descriptor cover many deployments whose hard-coded constant differs, e.g. a wrapper whose underlying token depends on which address (`@.to`) was called. + +- **Shape.** `params.token: { "map": "$.metadata.maps.underlying", "keyPath": "@.to" }`, where `metadata.maps.underlying.values` maps a key to the constant. `keyPath` is resolved like any other path; its value is reduced to a canonical string (`mapKeyFromResolved`) and matched **case-insensitively** against the map keys, so a checksummed address key matches a lowercased `@.to`. Guarded by `isMapReference`; typed as `DescriptorMapReference` / `DescriptorMetadataMap` in `types.ts`. +- **Where substitution happens.** `resolveParamMapReferences` (in `formatters.ts`) runs once per field in `processSingleField`, **before** rendering, replacing every map reference in the field's params with its resolved constant. Format handlers therefore never see a map reference; the `asConstant` guard in `formatters.ts` keeps them correct if driven directly. Values keep their JSON type, so a map yielding an integer still satisfies a numeric param like `decimals`. +- **Miss → `DESCRIPTOR_NOT_APPLICABLE`.** If no map key matches, the descriptor does not describe this transaction. `processSingleField` returns this warning and the whole format is abandoned (raw-calldata fallback), rather than degrading the single field — per the ERC-7730 spec, unrelated constants in the same descriptor may be equally out of date. This is distinct from `INVALID_DESCRIPTOR` (a malformed descriptor); the descriptor here is well-formed but out of scope. + ### Chain Info Resolution Chain metadata (name, native currency) is resolved via `ExternalDataProvider.resolveChainInfo(chainId)`. This is used by the `chainId` format (to display chain names), the `amount` format (to display native currency amounts with correct decimals and ticker), and the `tokenAmount` format when `nativeCurrencyAddress` matches. There is no embedded chain registry. When `resolveChainInfo` is absent or returns `null`, the library emits an `UNKNOWN_CHAIN` warning and falls back to the raw value. diff --git a/src/fields.ts b/src/fields.ts index e209483..9772700 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -50,7 +50,7 @@ import { warn, } from "./utils.js"; import type { RenderFieldResult } from "./formatters.js"; -import { renderField } from "./formatters.js"; +import { renderField, resolveParamMapReferences } from "./formatters.js"; /** Callback to get the length of an array at a given container path. */ export type GetArrayLength = (path: string) => number; @@ -174,10 +174,9 @@ async function processSingleField( fieldSpec: DescriptorFieldFormat, ctx: FieldContext, ): Promise<{ field: DisplayField | null } | { warnings: Warning[] }> { - const { merged, warnings: defWarnings } = mergeDefinitions( - fieldSpec, - ctx.definitions, - ); + const definitionResult = mergeDefinitions(fieldSpec, ctx.definitions); + const defWarnings = definitionResult.warnings; + let merged = definitionResult.merged; if (defWarnings.length > 0) { return { warnings: defWarnings.map((msg) => @@ -188,6 +187,27 @@ async function processSingleField( if (merged.visible === "never") return { field: null }; + // Substitute metadata.maps references in the field's params before anything + // is rendered. Per ERC-7730 a lookup miss means this descriptor does not + // describe the transaction, so the whole format is abandoned rather than + // rendered with a missing constant. + const mapParams = resolveParamMapReferences( + merged.params, + ctx.resolvePath, + ctx.metadata, + ); + if (!mapParams.ok) { + return { + warnings: [ + warn( + "DESCRIPTOR_NOT_APPLICABLE", + `No metadata.maps entry matches for param '${mapParams.unresolved}' of field '${merged.label ?? merged.path}'`, + ), + ], + }; + } + merged = { ...merged, params: mapParams.params }; + const resolvedValue = resolveFieldValue(merged, ctx.resolvePath); if (!resolvedValue) { if (merged.path?.startsWith("@.")) { diff --git a/src/formatters.ts b/src/formatters.ts index 1392cbd..a0b2fa6 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -6,8 +6,11 @@ import type { BlockTimestampResult, ChainInfoResult, DescriptorFieldFormat, + DescriptorFieldFormatParams, DescriptorFieldFormatType, + DescriptorMapReference, DescriptorMetadata, + DescriptorMetadataMap, EmbeddedCalldata, ExternalDataProvider, FormatCalldata, @@ -226,7 +229,7 @@ export async function formatTokenAmount( }; } - const chainIdResult = resolveChainId(field, resolvePath); + const chainIdResult = resolveChainId(field, resolvePath, metadata); if (chainIdResult.hasChainIdParam && chainIdResult.value === undefined) { return { rendered: renderRaw(value), @@ -250,7 +253,7 @@ export async function formatTokenAmount( }; } - const tokenAddress = resolveTokenAddress(field, resolvePath); + const tokenAddress = resolveTokenAddress(field, resolvePath, metadata); if (!tokenAddress) { return { rendered: renderRaw(value), @@ -372,7 +375,9 @@ export function resolveMetadataToken( | { hasMetadataRef: true; token: TokenResult | undefined } { const params = field.params ?? {}; const tokenSpec = params.token ?? params.tokenPath; - if (tokenSpec !== "$.metadata.token") return { hasMetadataRef: false }; + if (typeof tokenSpec !== "string" || tokenSpec !== "$.metadata.token") { + return { hasMetadataRef: false }; + } const meta = metadata?.token; if (!meta?.ticker || meta.decimals === undefined) { @@ -389,20 +394,161 @@ export function resolveMetadataToken( }; } +/** Type guard for a `{ map, keyPath }` map reference param. */ +export function isMapReference( + value: unknown, +): value is DescriptorMapReference { + return ( + typeof value === "object" && + value !== null && + typeof (value as DescriptorMapReference).map === "string" && + typeof (value as DescriptorMapReference).keyPath === "string" + ); +} + +/** + * Narrow a param that may carry a map reference down to its constant form. + * + * References are substituted by {@link resolveParamMapReferences} before any + * field is rendered, so by the time a format handler reads a param it is always + * a constant. This guard keeps the handlers honest for the case where they are + * driven directly, treating a leftover reference as an absent param. + */ +function asConstant(value: T | DescriptorMapReference): T | undefined { + return isMapReference(value) ? undefined : (value as T); +} + +/** + * Canonical string form of a resolved path value, for use as a `metadata.maps` + * lookup key. Addresses and byte strings are lowercased hex so that + * checksummed keys in the descriptor still match; integers are decimal. + */ +function mapKeyFromResolved( + value: ReturnType, +): string | undefined { + if (!value) return undefined; + switch (value.type) { + case "address": + return bytesToHex(value.bytes).toLowerCase(); + case "bytes": + case "bytes-slice": + return bytesToHex(value.bytes).toLowerCase(); + case "uint": + case "int": + return value.value.toString(); + case "string": + return value.value; + case "bool": + return value.value ? "true" : "false"; + default: + return undefined; + } +} + +/** + * Resolve a `{ map, keyPath }` reference against `metadata.maps`. + * + * Returns undefined when the map is unknown, the key cannot be resolved, or no + * entry matches. Per ERC-7730 a miss means the descriptor does not apply to + * this transaction, so callers surface it as a resolution failure rather than + * silently substituting a default. + */ +export function resolveMapReference( + ref: DescriptorMapReference, + resolvePath: ResolvePath, + metadata: DescriptorMetadata | undefined, +): string | number | boolean | undefined { + const mapDef = resolveMetadataValue(metadata, ref.map) as + | DescriptorMetadataMap + | undefined; + const values = mapDef?.values; + if (!values || typeof values !== "object") return undefined; + + const key = mapKeyFromResolved(resolvePath(ref.keyPath)); + if (key === undefined) return undefined; + + // Direct hit first, then a case-insensitive sweep so that checksummed + // address keys match a lowercased resolved key (and vice versa). + const direct = values[key]; + if (direct !== undefined) return direct; + + const lowered = key.toLowerCase(); + for (const [candidate, mapped] of Object.entries(values)) { + if (candidate.toLowerCase() === lowered) return mapped; + } + + return undefined; +} + +/** + * Substitute every `metadata.maps` reference in a params object with its + * resolved constant. + * + * Per ERC-7730, a map reference may stand in for any constant parameter value, + * so substitution is generic rather than per-format: `token`, `threshold`, + * `nativeCurrencyAddress`, `senderAddress`, the `unit` scale and `chainId` are + * all handled by the same pass. Values keep their JSON type, so a map yielding + * an integer still satisfies a numeric param such as `decimals`. + * + * A lookup miss means the descriptor does not describe the transaction it is + * being applied to, so the caller MUST abandon the whole format rather than + * render the remaining fields — the returned `unresolved` entry names the + * offending parameter for the diagnostic. + */ +export function resolveParamMapReferences( + params: DescriptorFieldFormatParams | undefined, + resolvePath: ResolvePath, + metadata: DescriptorMetadata | undefined, +): + | { ok: true; params: DescriptorFieldFormatParams | undefined } + | { ok: false; unresolved: string } { + if (!params) return { ok: true, params }; + + let substituted: Record | undefined; + for (const [name, value] of Object.entries(params)) { + if (!isMapReference(value)) continue; + const mapped = resolveMapReference(value, resolvePath, metadata); + if (mapped === undefined) { + return { ok: false, unresolved: name }; + } + substituted ??= { ...(params as Record) }; + substituted[name] = mapped; + } + + return { + ok: true, + params: (substituted as DescriptorFieldFormatParams | undefined) ?? params, + }; +} + /** * Resolve the ERC-20 token address for a tokenAmount field. * * Per the spec, `token` takes priority over `tokenPath`. Both can be either - * a constant address or a path reference. + * a constant address, a path reference, or (for `token`) a `metadata.maps` + * reference for context-dependent constants. */ export function resolveTokenAddress( field: FieldFormatOptions, resolvePath: ResolvePath, + metadata?: DescriptorMetadata, ): string | undefined { const params = field.params ?? {}; const token = params.token ?? params.tokenPath; if (!token) return undefined; + // Context-dependent constant via metadata.maps. Normally already substituted + // by resolveParamMapReferences before rendering; handled here too so the + // helper is correct when called directly. + if (isMapReference(token)) { + const mapped = resolveMapReference(token, resolvePath, metadata); + return typeof mapped === "string" && isAddressString(mapped) + ? mapped.toLowerCase() + : undefined; + } + + if (typeof token !== "string") return undefined; + // Constant address if (isAddressString(token)) { return token.toLowerCase(); @@ -528,7 +674,7 @@ export function resolveCollectionAddress( resolvePath: ResolvePath, ): string | undefined { const params = field.params ?? {}; - const collection = params.collection ?? params.collectionPath; + const collection = asConstant(params.collection) ?? params.collectionPath; if (!collection) return undefined; // Constant address @@ -670,8 +816,8 @@ export function formatUnit( return typeMismatch(value, "uint or int", "unit"); const params = fieldOptions.params ?? {}; - const base = resolveUnitBase(params.base, metadata); - const decimals = params.decimals ?? 0; + const base = resolveUnitBase(asConstant(params.base), metadata); + const decimals = asConstant(params.decimals) ?? 0; const prefix = params.prefix === true; const formatted = formatAmountWithDecimals(value.value, decimals); @@ -901,7 +1047,7 @@ function resolveCallee( resolvePath: ResolvePath, ): string | undefined { const params = field.params ?? {}; - const spec = params.callee ?? params.calleePath; + const spec = asConstant(params.callee) ?? params.calleePath; if (!spec) return undefined; if (isAddressString(spec)) { @@ -925,7 +1071,7 @@ function resolveAmountParam( resolvePath: ResolvePath, ): bigint | undefined { const params = field.params ?? {}; - const spec = params.amount ?? params.amountPath; + const spec = asConstant(params.amount) ?? params.amountPath; if (!spec) return undefined; const resolved = resolvePath(spec); @@ -954,7 +1100,7 @@ function resolveSpenderParam( resolvePath: ResolvePath, ): string | undefined { const params = field.params ?? {}; - const spec = params.spender ?? params.spenderPath; + const spec = asConstant(params.spender) ?? params.spenderPath; if (!spec) return undefined; if (isAddressString(spec)) { @@ -978,7 +1124,7 @@ function resolveSelectorParam( resolvePath: ResolvePath, ): Uint8Array | undefined { const params = field.params ?? {}; - const spec = params.selector ?? params.selectorPath; + const spec = asConstant(params.selector) ?? params.selectorPath; if (!spec) return undefined; if (typeof spec === "string" && spec.startsWith("0x") && spec.length === 10) { @@ -1188,6 +1334,7 @@ export async function formatTokenTicker( function resolveChainId( field: FieldFormatOptions, resolvePath: ResolvePath, + metadata?: DescriptorMetadata, ): | { hasChainIdParam: false } | { hasChainIdParam: true; value: number | undefined } { @@ -1197,6 +1344,15 @@ function resolveChainId( if (typeof spec === "number") return { hasChainIdParam: true, value: spec }; + if (isMapReference(spec)) { + const mapped = resolveMapReference(spec, resolvePath, metadata); + const n = mapped === undefined ? NaN : Number(mapped); + return { + hasChainIdParam: true, + value: Number.isInteger(n) && n > 0 ? n : undefined, + }; + } + if (typeof spec === "string") { const n = Number(spec); if (Number.isInteger(n) && n > 0) diff --git a/src/types.ts b/src/types.ts index 5153d07..b477a60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,7 @@ export type WarningCode = | "UNKNOWN_NFT_COLLECTION" | "BUNDLED_ARRAY_SIZE_MISMATCH" | "FORMAT_PARAM_RESOLUTION_ERROR" + | "DESCRIPTOR_NOT_APPLICABLE" | "UNKNOWN_ENCODING" | "UNKNOWN_BLOCK" | "UNKNOWN_CHAIN" @@ -606,32 +607,42 @@ export interface DescriptorFieldEncryption { fallbackLabel?: string; } +/** + * A reference to a `metadata.maps` entry, usable anywhere a constant param is + * accepted. `map` is a `$.metadata.maps.NAME` pointer; `keyPath` points at the + * transaction/message value used to select the entry. + */ +export interface DescriptorMapReference { + map: string; + keyPath: string; +} + export interface DescriptorFieldFormatParams { tokenPath?: string; - token?: string; - nativeCurrencyAddress?: string | string[]; - threshold?: string | number; + token?: string | DescriptorMapReference; + nativeCurrencyAddress?: string | string[] | DescriptorMapReference; + threshold?: string | number | DescriptorMapReference; message?: string; chainIdPath?: string; - chainId?: number; + chainId?: number | DescriptorMapReference; encoding?: "timestamp" | "blockheight"; - base?: string; - decimals?: number; + base?: string | DescriptorMapReference; + decimals?: number | DescriptorMapReference; prefix?: boolean; $ref?: string; collectionPath?: string; - collection?: string; + collection?: string | DescriptorMapReference; calleePath?: string; - callee?: string; + callee?: string | DescriptorMapReference; selectorPath?: string; - selector?: string; + selector?: string | DescriptorMapReference; amountPath?: string; - amount?: string; + amount?: string | DescriptorMapReference; spenderPath?: string; - spender?: string; + spender?: string | DescriptorMapReference; types?: DescriptorAddressType[]; sources?: DescriptorAddressSource[]; - senderAddress?: string | string[]; + senderAddress?: string | string[] | DescriptorMapReference; } export interface DescriptorFieldFormat { @@ -710,13 +721,19 @@ export interface DescriptorMetadataToken { decimals?: number; } +export interface DescriptorMetadataMap { + /** Non-normative hint describing what the map is keyed on. */ + $keyType?: string; + values?: Record; +} + export interface DescriptorMetadata { owner?: string; contractName?: string; info?: DescriptorMetadataInfo; token?: DescriptorMetadataToken; constants?: Record; - maps?: Record; + maps?: Record; enums?: Record>; } diff --git a/test/formatters.spec.ts b/test/formatters.spec.ts index a5683ff..206ab02 100644 --- a/test/formatters.spec.ts +++ b/test/formatters.spec.ts @@ -14,6 +14,9 @@ import { renderTokenAmount, tokenAmountMessage, resolveTokenAddress, + resolveMapReference, + resolveParamMapReferences, + isMapReference, formatDate, formatTimestamp, formatEnum, @@ -2103,3 +2106,251 @@ describe("renderField", () => { expect(result.rendered).toBe("7"); }); }); + +// --------------------------------------------------------------------------- +// metadata.maps references +// --------------------------------------------------------------------------- + +describe("isMapReference", () => { + it("accepts a well-formed map reference", () => { + expect( + isMapReference({ map: "$.metadata.maps.underlying", keyPath: "@.to" }), + ).toBe(true); + }); + + it("rejects strings, null and partial objects", () => { + expect(isMapReference("$.metadata.maps.underlying")).toBe(false); + expect(isMapReference(null)).toBe(false); + expect(isMapReference({ map: "$.metadata.maps.underlying" })).toBe(false); + expect(isMapReference({ keyPath: "@.to" })).toBe(false); + }); +}); + +describe("resolveMapReference", () => { + const metadata: DescriptorMetadata = { + maps: { + underlying: { + $keyType: "wrapper address", + values: { + "0xe978F22157048E5DB8E5d07971376e86671672B2": + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + }, + byChain: { + values: { "1": "0x0000000000000000000000000000000000000001" }, + }, + }, + }; + + const resolveTo = + (bytes: Uint8Array): ResolvePath => + (path) => + path === "@.to" ? { type: "address", bytes } : undefined; + + it("resolves a checksummed key from a lowercased address value", () => { + const resolve = resolveTo( + hexToBytes("0xda9396b82634Ea99243cE51258B6A5Ae512D4893"), + ); + expect( + resolveMapReference( + { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + resolve, + metadata, + ), + ).toBe("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + }); + + it("resolves an integer key", () => { + const resolve: ResolvePath = (path) => + path === "@.chainId" ? { type: "uint", value: 1n } : undefined; + expect( + resolveMapReference( + { map: "$.metadata.maps.byChain", keyPath: "@.chainId" }, + resolve, + metadata, + ), + ).toBe("0x0000000000000000000000000000000000000001"); + }); + + it("returns undefined on a key miss", () => { + const resolve = resolveTo( + hexToBytes("0x0000000000000000000000000000000000000009"), + ); + expect( + resolveMapReference( + { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + resolve, + metadata, + ), + ).toBeUndefined(); + }); + + it("returns undefined for an unknown map or missing metadata", () => { + const resolve = resolveTo( + hexToBytes("0xda9396b82634Ea99243cE51258B6A5Ae512D4893"), + ); + expect( + resolveMapReference( + { map: "$.metadata.maps.nope", keyPath: "@.to" }, + resolve, + metadata, + ), + ).toBeUndefined(); + expect( + resolveMapReference( + { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + resolve, + undefined, + ), + ).toBeUndefined(); + }); + + it("returns undefined when the keyPath itself does not resolve", () => { + expect( + resolveMapReference( + { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + noopResolvePath, + metadata, + ), + ).toBeUndefined(); + }); +}); + +describe("resolveTokenAddress with a map reference", () => { + const metadata: DescriptorMetadata = { + maps: { + underlying: { + values: { + "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + }, + broken: { values: { "0x1": "not-an-address" } }, + }, + }; + + const field = { + params: { token: { map: "$.metadata.maps.underlying", keyPath: "@.to" } }, + }; + + it("resolves the underlying token for the called wrapper", () => { + const resolve: ResolvePath = (path) => + path === "@.to" + ? { + type: "address", + bytes: hexToBytes("0xda9396b82634Ea99243cE51258B6A5Ae512D4893"), + } + : undefined; + expect(resolveTokenAddress(field, resolve, metadata)).toBe( + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + ); + }); + + it("returns undefined on a miss instead of throwing", () => { + const resolve: ResolvePath = (path) => + path === "@.to" + ? { + type: "address", + bytes: hexToBytes("0x0000000000000000000000000000000000000009"), + } + : undefined; + expect(resolveTokenAddress(field, resolve, metadata)).toBeUndefined(); + }); + + it("returns undefined when the mapped value is not an address", () => { + const brokenField = { + params: { token: { map: "$.metadata.maps.broken", keyPath: "@.to" } }, + }; + const resolve: ResolvePath = () => ({ type: "string", value: "0x1" }); + expect(resolveTokenAddress(brokenField, resolve, metadata)).toBeUndefined(); + }); + + it("does not treat a map reference as a $.metadata.token reference", () => { + expect(resolveMetadataToken(field, {}).hasMetadataRef).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// metadata.maps: generic param substitution +// --------------------------------------------------------------------------- + +describe("resolveParamMapReferences", () => { + const metadata: DescriptorMetadata = { + maps: { + underlying: { + values: { + "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + }, + scale: { values: { "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": 18 } }, + }, + }; + const resolveTo: ResolvePath = (path) => + path === "@.to" + ? { + type: "address", + bytes: hexToBytes("0xda9396b82634Ea99243cE51258B6A5Ae512D4893"), + } + : undefined; + + it("passes through params with no map reference", () => { + const params = { tokenPath: "@.to", threshold: "0x800" }; + const result = resolveParamMapReferences(params, resolveTo, metadata); + expect(result.ok).toBe(true); + if (result.ok) expect(result.params).toBe(params); + }); + + it("returns undefined params untouched", () => { + const result = resolveParamMapReferences(undefined, resolveTo, metadata); + expect(result.ok).toBe(true); + if (result.ok) expect(result.params).toBeUndefined(); + }); + + it("substitutes a map reference on any constant param", () => { + // decimals is one of the params the schema newly declares map-capable. + const result = resolveParamMapReferences( + { + token: { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + decimals: { map: "$.metadata.maps.scale", keyPath: "@.to" }, + }, + resolveTo, + metadata, + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.params?.token).toBe( + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + ); + // Values keep their JSON type, so a numeric param stays numeric. + expect(result.params?.decimals).toBe(18); + } + }); + + it("reports the offending param on a lookup miss", () => { + const resolveMissing: ResolvePath = () => ({ + type: "address", + bytes: hexToBytes("0x0000000000000000000000000000000000000009"), + }); + const result = resolveParamMapReferences( + { token: { map: "$.metadata.maps.underlying", keyPath: "@.to" } }, + resolveMissing, + metadata, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.unresolved).toBe("token"); + }); + + it("does not mutate the params object it was given", () => { + const params = { + token: { map: "$.metadata.maps.underlying", keyPath: "@.to" }, + }; + resolveParamMapReferences(params, resolveTo, metadata); + expect(params.token).toEqual({ + map: "$.metadata.maps.underlying", + keyPath: "@.to", + }); + }); +}); diff --git a/test/registry-cases/zama/calldata-ConfidentialWrapper.json b/test/registry-cases/zama/calldata-ConfidentialWrapper.json index ea92f03..da4e576 100644 --- a/test/registry-cases/zama/calldata-ConfidentialWrapper.json +++ b/test/registry-cases/zama/calldata-ConfidentialWrapper.json @@ -79,13 +79,42 @@ ] } }, - "metadata": { "contractName": "ConfidentialWrapper" }, + "metadata": { + "contractName": "ConfidentialWrapper", + "maps": { + "underlying": { + "$keyType": "wrapper address", + "values": { + "0xe978F22157048E5DB8E5d07971376e86671672B2": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xAe0207C757Aa2B4019Ad96edD0092ddc63EF0c50": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "0x85dE671c3bec1aDeD752c3Cea943521181C826bc": "0xBA2C598E11eD093079cC324FCa5BbbA99F616E83", + "0x80CB147Fd86dC6dEe3Eee7e4Cee33d1397d98071": "0xA12CC123ba206d4031D1c7f6223D1C2Ec249f4f3", + "0xa873750ccBafD5ec7Dd13bfD5237d7129832eDD9": "0x27f6c8289550fCE67f6B50BeD1F519966aFE5287", + "0x73cc9aF9d6BEFdb3c3fAf8a5E8c05Cb95FdaEEf1": "0x68749665FF8D2d112Fa859AA293F07A622782F38", + "0xBA4cFF6ED6F7Cb2A58776dECa4E984b498446762": "0xbeeffABcd0dB09589Dd21854aa760C52aB4bf04F", + "0x66Bf74E96900D1a19c7070D939D124f2F565C458": "0xbEEF00A59B577423653A1526c7009bdE103F542B", + "0x7c5BF43B851c1dff1a4feE8dB225b87f2C223639": "0x9b5Cd13b8eFbB58Dc25A05CF411D8056058aDFfF", + "0x4E7B06D78965594eB5EF5414c357ca21E1554491": "0xa7dA08FafDC9097Cc0E7D4f113A61e31d7e8e9b0", + "0x46208622DA27d91db4f0393733C8BA082ed83158": "0xff54739b16576FA5402F211D0b938469Ab9A5f3F", + "0xaa5612FA27c927a0c7961f5AEFEE5ba3A0F9C891": "0xFf021fB13cA64e5354c62c954b949a88cfDEb25E", + "0xf2D628d2598aF4eAF94CB76a437Ff86CA78FfbFB": "0x75355a85c6FB9df5f0C80FF54e8747EEe9a0BF57", + "0xfCE5c7069c5525eF6c8C2b2E35A745bA20a2F7CC": "0x93c931278A2aad1916783F952f94276eA5111442", + "0xe4FcF848739845BC81Dee1d5352cf3844F0a60C7": "0x24377AE4AA0C45ecEe71225007f17c5D423dd940", + "0x167DC962808B32CFFFc7e14B5018c0bE06A3A208": "0xf6Ef9ADB61A48E29E36bc873070A46A3D2667ff3", + "0x13F7d34A4f0102734F19E3Ff16e068Fe194B28c4": "0x6AB54988261AEC573a2CA13cF802d3B1114f864C" + } + } + } + }, "display": { "definitions": { "encryptedAmount": { "label": "Amount", "format": "tokenAmount", - "params": { "tokenPath": "@.to" }, + "params": { + "tokenPath": "@.to" + }, "encryption": { "scheme": "fhevm", "plaintextType": "uint64", @@ -95,44 +124,85 @@ "receiver": { "label": "Receiver", "format": "addressName", - "params": { "types": ["eoa", "contract"] } + "params": { + "types": ["eoa", "contract"] + } }, "holder": { "label": "Holder", "format": "addressName", - "params": { "types": ["eoa", "contract"] } + "params": { + "types": ["eoa", "contract"] + } + }, + "inputProof": { + "label": "Encryption proof", + "format": "raw" }, - "inputProof": { "label": "Encryption proof", "format": "raw" }, - "decryptionProof": { "label": "Decryption proof", "format": "raw" }, - "callbackData": { "label": "Callback data", "format": "raw" } + "decryptionProof": { + "label": "Decryption proof", + "format": "raw" + }, + "callbackData": { + "label": "Callback data", + "format": "raw" + } }, "formats": { - "wrap(address to, uint256 amount)": { + "wrap(address to,uint256 amount)": { "$id": "wrap", "intent": "Shield", "interpolatedIntent": "Shield {amount} to {to}", "fields": [ - { "path": "amount", "label": "Amount", "format": "raw" }, - { "path": "to", "$ref": "$.display.definitions.receiver" } + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlying", + "keyPath": "@.to" + } + } + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + } ] }, - "unwrap(address from, address to, bytes32 amount)": { + "unwrap(address from,address to,bytes32 amount)": { "$id": "unwrap", "intent": "Request to unshield", - "interpolatedIntent": "Request to unshield from {from} to {to}", + "interpolatedIntent": "Request to unshield {amount} from {from} to {to}", "fields": [ - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, - { "path": "amount", "$ref": "$.display.definitions.encryptedAmount" } + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, + { + "path": "amount", + "$ref": "$.display.definitions.encryptedAmount" + } ] }, - "unwrap(address from, address to, bytes32 encryptedAmount, bytes inputProof)": { + "unwrap(address from,address to,bytes32 encryptedAmount,bytes inputProof)": { "$id": "unwrapWithProof", "intent": "Request to unshield", - "interpolatedIntent": "Request to unshield from {from} to {to}", + "interpolatedIntent": "Request to unshield {encryptedAmount} from {from} to {to}", "fields": [ - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "encryptedAmount", "$ref": "$.display.definitions.encryptedAmount" @@ -144,7 +214,7 @@ } ] }, - "finalizeUnwrap(bytes32 unwrapRequestId, uint64 unwrapAmountCleartext, bytes decryptionProof)": { + "finalizeUnwrap(bytes32 unwrapRequestId,uint64 unwrapAmountCleartext,bytes decryptionProof)": { "$id": "finalizeUnwrap", "intent": "Finalize unshield", "interpolatedIntent": "Finalize unshield of {unwrapAmountCleartext}", @@ -153,9 +223,15 @@ "path": "unwrapAmountCleartext", "label": "Amount", "format": "tokenAmount", - "params": { "tokenPath": "@.to" } + "params": { + "tokenPath": "@.to" + } + }, + { + "path": "unwrapRequestId", + "label": "Request ID", + "format": "raw" }, - { "path": "unwrapRequestId", "label": "Request ID", "format": "raw" }, { "path": "decryptionProof", "$ref": "$.display.definitions.decryptionProof", @@ -163,16 +239,22 @@ } ] }, - "confidentialTransfer(address to, bytes32 amount)": { + "confidentialTransfer(address to,bytes32 amount)": { "$id": "cTransfer", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {amount} to {to}", "fields": [ - { "path": "amount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "to", "$ref": "$.display.definitions.receiver" } + { + "path": "amount", + "$ref": "$.display.definitions.encryptedAmount" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + } ] }, - "confidentialTransfer(address to, bytes32 encryptedAmount, bytes inputProof)": { + "confidentialTransfer(address to,bytes32 encryptedAmount,bytes inputProof)": { "$id": "cTransferWithProof", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {encryptedAmount} to {to}", @@ -181,7 +263,10 @@ "path": "encryptedAmount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "inputProof", "$ref": "$.display.definitions.inputProof", @@ -189,17 +274,26 @@ } ] }, - "confidentialTransferFrom(address from, address to, bytes32 amount)": { + "confidentialTransferFrom(address from,address to,bytes32 amount)": { "$id": "cTransferFrom", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {amount} from {from} to {to}", "fields": [ - { "path": "amount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" } + { + "path": "amount", + "$ref": "$.display.definitions.encryptedAmount" + }, + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + } ] }, - "confidentialTransferFrom(address from, address to, bytes32 encryptedAmount, bytes inputProof)": { + "confidentialTransferFrom(address from,address to,bytes32 encryptedAmount,bytes inputProof)": { "$id": "cTransferFromWithProof", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {encryptedAmount} from {from} to {to}", @@ -208,8 +302,14 @@ "path": "encryptedAmount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "inputProof", "$ref": "$.display.definitions.inputProof", @@ -217,13 +317,19 @@ } ] }, - "confidentialTransferAndCall(address to, bytes32 amount, bytes data)": { + "confidentialTransferAndCall(address to,bytes32 amount,bytes data)": { "$id": "cTransferAndCall", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {amount} to {to}", "fields": [ - { "path": "amount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "amount", + "$ref": "$.display.definitions.encryptedAmount" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "data", "$ref": "$.display.definitions.callbackData", @@ -231,7 +337,7 @@ } ] }, - "confidentialTransferAndCall(address to, bytes32 encryptedAmount, bytes inputProof, bytes data)": { + "confidentialTransferAndCall(address to,bytes32 encryptedAmount,bytes inputProof,bytes data)": { "$id": "cTransferAndCallWithProof", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {encryptedAmount} to {to}", @@ -240,7 +346,10 @@ "path": "encryptedAmount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "inputProof", "$ref": "$.display.definitions.inputProof", @@ -253,14 +362,23 @@ } ] }, - "confidentialTransferFromAndCall(address from, address to, bytes32 amount, bytes data)": { + "confidentialTransferFromAndCall(address from,address to,bytes32 amount,bytes data)": { "$id": "cTransferFromAndCall", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {amount} from {from} to {to}", "fields": [ - { "path": "amount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "amount", + "$ref": "$.display.definitions.encryptedAmount" + }, + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "data", "$ref": "$.display.definitions.callbackData", @@ -268,7 +386,7 @@ } ] }, - "confidentialTransferFromAndCall(address from, address to, bytes32 encryptedAmount, bytes inputProof, bytes data)": { + "confidentialTransferFromAndCall(address from,address to,bytes32 encryptedAmount,bytes inputProof,bytes data)": { "$id": "cTransferFromAndCallWithProof", "intent": "Confidential transfer", "interpolatedIntent": "Confidential transfer of {encryptedAmount} from {from} to {to}", @@ -277,8 +395,14 @@ "path": "encryptedAmount", "$ref": "$.display.definitions.encryptedAmount" }, - { "path": "from", "$ref": "$.display.definitions.holder" }, - { "path": "to", "$ref": "$.display.definitions.receiver" }, + { + "path": "from", + "$ref": "$.display.definitions.holder" + }, + { + "path": "to", + "$ref": "$.display.definitions.receiver" + }, { "path": "inputProof", "$ref": "$.display.definitions.inputProof", @@ -291,28 +415,33 @@ } ] }, - "setOperator(address operator, uint48 until)": { + "setOperator(address operator,uint48 until)": { "$id": "setOperator", - "intent": "Authorize operator", - "interpolatedIntent": "Authorize operator {operator} until {until}", + "intent": "Set operator authorization", + "interpolatedIntent": "Set {operator} authorization to expire on {until}", "fields": [ { "path": "operator", - "label": "Authorize operator", + "label": "Operator", "format": "addressName", - "params": { "types": ["eoa", "contract"] } + "params": { + "types": ["eoa", "contract"] + } }, { "path": "until", "label": "Authorized until", "format": "date", - "params": { "encoding": "timestamp" } + "params": { + "encoding": "timestamp" + } } ] }, "requestDiscloseEncryptedAmount(bytes32 encryptedAmount)": { "$id": "requestDiscloseEncryptedAmount", "intent": "Request public disclosure", + "interpolatedIntent": "Request public disclosure of {encryptedAmount}", "fields": [ { "path": "encryptedAmount", @@ -320,20 +449,31 @@ } ] }, - "discloseEncryptedAmount(bytes32 encryptedAmount, uint64 cleartextAmount, bytes decryptionProof)": { + "discloseEncryptedAmount(bytes32 encryptedAmount,uint64 cleartextAmount,bytes decryptionProof)": { "$id": "discloseEncryptedAmount", - "intent": "Disclose amount publicly", - "interpolatedIntent": "Disclose {cleartextAmount} publicly", + "intent": "Disclose encrypted amount publicly", + "interpolatedIntent": "Disclose {encryptedAmount} publicly as {cleartextAmount}", "fields": [ { - "path": "cleartextAmount", - "label": "Amount", + "path": "encryptedAmount", + "label": "Encrypted amount reference", "format": "tokenAmount", - "params": { "tokenPath": "@.to" } + "params": { + "tokenPath": "@.to" + }, + "encryption": { + "scheme": "fhevm", + "plaintextType": "uint64", + "fallbackLabel": "[Encrypted Amount]" + } }, { - "path": "encryptedAmount", - "$ref": "$.display.definitions.encryptedAmount" + "path": "cleartextAmount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "@.to" + } }, { "path": "decryptionProof", diff --git a/test/registry-cases/zama/calldata-MapMissWrapper.json b/test/registry-cases/zama/calldata-MapMissWrapper.json new file mode 100644 index 0000000..a985000 --- /dev/null +++ b/test/registry-cases/zama/calldata-MapMissWrapper.json @@ -0,0 +1,59 @@ +{ + "$schema": "../../specs/erc7730-v2.schema.json", + "context": { + "$id": "MapMissWrapper", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0xda9396b82634Ea99243cE51258B6A5Ae512D4893" + }, + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000099" + } + ] + } + }, + "metadata": { + "contractName": "MapMissWrapper", + "maps": { + "underlying": { + "$keyType": "wrapper address", + "values": { + "0xda9396b82634Ea99243cE51258B6A5Ae512D4893": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + } + } + } + }, + "display": { + "formats": { + "wrap(address to,uint256 amount)": { + "$id": "wrap", + "intent": "Shield", + "interpolatedIntent": "Shield {amount} to {to}", + "fields": [ + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlying", + "keyPath": "@.to" + } + } + }, + { + "path": "to", + "label": "Receiver", + "format": "addressName", + "params": { + "types": ["eoa", "contract"] + } + } + ] + } + } + } +} diff --git a/test/registry-cases/zama/zama.spec.ts b/test/registry-cases/zama/zama.spec.ts index bf79334..b90117d 100644 --- a/test/registry-cases/zama/zama.spec.ts +++ b/test/registry-cases/zama/zama.spec.ts @@ -339,4 +339,115 @@ describe("Zama ConfidentialWrapper", () => { expect(result.warnings).toBeUndefined(); }); }); + + // ========================================================================= + // wrap: the underlying token comes from metadata.maps, keyed on @.to + // ========================================================================= + describe("wrap (metadata.maps)", () => { + // wrap(address to, uint256 amount) + const WRAP_SELECTOR = "0xbf376c7a"; + const RECEIVER_WORD = + "00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8"; + const CWETH = "0xda9396b82634Ea99243cE51258B6A5Ae512D4893"; + const WETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; + const UNMAPPED = "0x0000000000000000000000000000000000000099"; + + const resolveUnderlying: ExternalDataProvider["resolveToken"] = async ( + chainId, + tokenAddress, + ) => { + if (chainId === CHAIN_ID && tokenAddress === WETH) { + return { name: "Wrapped Ether", symbol: "WETH", decimals: 18 }; + } + return null; + }; + + it("resolves the underlying token of the called wrapper", async () => { + const opts = buildFilesystemResolverOpts( + __dirname, + { + calldataDescriptorFiles: [ + { + chainId: CHAIN_ID, + address: CWETH, + file: "calldata-ConfidentialWrapper.json", + }, + ], + }, + { resolveToken: resolveUnderlying }, + ); + + const result: DisplayModel = await format( + { + chainId: CHAIN_ID, + to: CWETH, + data: + WRAP_SELECTOR + + RECEIVER_WORD + + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", + }, + opts, + ); + + // 1e18 formatted with the *underlying's* 18 decimals, not the wrapper's 6. + assert(result.fields); + const amountField = result.fields[0]; + assert(!isFieldGroup(amountField)); + expect(amountField.label).toBe("Amount"); + expect(amountField.value).toBe("1 WETH"); + expect(amountField.format).toBe("tokenAmount"); + expect(amountField.fieldType).toBe("uint"); + expect(amountField.tokenAddress).toBe( + toChecksumAddress(hexToBytes(WETH)), + ); + expect(amountField.warning).toBeUndefined(); + + expect(result.intent).toBe("Shield"); + expect(result.interpolatedIntent).toBe( + `Shield 1 WETH to ${toChecksumAddress(hexToBytes(RECEIVER))}`, + ); + expect(result.rawCalldataFallback).toBeUndefined(); + expect(result.warnings).toBeUndefined(); + }); + + it("abandons the whole format when no map entry matches the deployment", async () => { + // The descriptor lists this deployment but its map has no entry for it — + // per ERC-7730 the descriptor does not describe this transaction, so the + // wallet must fall back rather than render a partially resolved format. + const opts = buildFilesystemResolverOpts( + __dirname, + { + calldataDescriptorFiles: [ + { + chainId: CHAIN_ID, + address: UNMAPPED, + file: "calldata-MapMissWrapper.json", + }, + ], + }, + { resolveToken: resolveUnderlying }, + ); + + const result: DisplayModel = await format( + { + chainId: CHAIN_ID, + to: UNMAPPED, + data: + WRAP_SELECTOR + + RECEIVER_WORD + + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", + }, + opts, + ); + + expect(result.fields).toBeUndefined(); + expect(result.intent).toBeUndefined(); + expect(result.interpolatedIntent).toBeUndefined(); + expect(result.rawCalldataFallback?.selector).toBe(WRAP_SELECTOR); + expect(result.warnings?.map((w) => w.code)).toEqual([ + "DESCRIPTOR_NOT_APPLICABLE", + ]); + expect(result.warnings?.[0].message).toContain("token"); + }); + }); });