Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 25 additions & 5 deletions src/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) =>
Expand All @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description does not match the code. The description says: when a map lookup fails, the field falls back to raw with FORMAT_PARAM_RESOLUTION_ERROR. The code does something different. When a map lookup fails, the code emits DESCRIPTOR_NOT_APPLICABLE and drops the whole format. The test "abandons the whole format" checks this. I think the code is correct. The spec says the descriptor is invalid for that transaction. Please update the PR description to match the code.

Posted with Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed, updated the PR description

`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("@.")) {
Expand Down
178 changes: 167 additions & 11 deletions src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import type {
BlockTimestampResult,
ChainInfoResult,
DescriptorFieldFormat,
DescriptorFieldFormatParams,
DescriptorFieldFormatType,
DescriptorMapReference,
DescriptorMetadata,
DescriptorMetadataMap,
EmbeddedCalldata,
ExternalDataProvider,
FormatCalldata,
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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) {
Expand All @@ -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<T>(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<ResolvePath>,
): 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<string, unknown> | 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<string, unknown>) };
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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -1188,6 +1334,7 @@ export async function formatTokenTicker(
function resolveChainId(
field: FieldFormatOptions,
resolvePath: ResolvePath,
metadata?: DescriptorMetadata,
):
| { hasChainIdParam: false }
| { hasChainIdParam: true; value: number | undefined } {
Expand All @@ -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)
Expand Down
Loading