Skip to content
Merged
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,9 @@ Tests live in `test/`. Current test files:
`rawEncryptedValue` is deliberately **not** in that list — only fields carrying an
`encryption` annotation ever set it, so assert it in encryption tests (both on success
and on fallback) and leave it out of the others rather than adding `undefined` checks
across the suite.
across the suite. The same applies to `separator` — only iterated array elements whose
descriptor defines a `separator` ever set it, so assert it (on both the elements that
carry it and their siblings that don't) in tests exercising separators only.
- **Test nested `DisplayModel`s** (e.g. `embeddedCalldata.display`) with the same thoroughness.
For calldata fields also assert `embeddedCalldata.callee` and `embeddedCalldata.chainId`.
Extract a helper function (e.g. `assertNestedDistribute`) when the same nested structure
Expand Down
6 changes: 6 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ interface DisplayField {
label: string; // UI label, e.g. "Amount to approve"
value: string; // Pre-formatted display value, e.g. "1 USDC"

// For iterated array elements whose descriptor defines a `separator`:
// the separator text with `{index}` substituted (e.g. "Recipient 0").
// When present, it MUST be displayed before this field (per ERC-7730) —
// e.g. as a sub-heading between array elements.
separator?: string;

// `fieldType` (the underlying Solidity type) and `format` (the ERC-7730
// display format, e.g. "tokenAmount") drive type-specific UI components.
// For example, for a `fieldType` of "address" the wallet can display an
Expand Down
14 changes: 8 additions & 6 deletions src/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,19 +286,21 @@ async function processSingleField(
rawAddress,
} = renderResult;

// Apply separator prefix for array elements (e.g. "Recipient {index}" → "Recipient 0")
let finalValue = rendered;
// Resolve the separator for array elements (e.g. "Recipient {index}" →
// "Recipient 0"). Exposed on the DisplayField for wallets to display
// before the field; kept out of value and renderedValues.
let separator: string | undefined;
if (merged.separator && merged.path) {
const indexMatch = merged.path.match(/\.\[(\d+)\]/);
if (indexMatch) {
const sep = merged.separator.replace("{index}", indexMatch[1]);
finalValue = `${sep} ${rendered}`;
separator = merged.separator.replace("{index}", indexMatch[1]);
}
}

const displayField: DisplayField = {
label: merged.label,
value: finalValue,
value: rendered,
...(separator && { separator }),
fieldType: argValue.type,
format: merged.format,
warning: fieldWarning,
Expand All @@ -309,7 +311,7 @@ async function processSingleField(
};

if (merged.path) {
ctx.renderedValues.set(stripStructuredRootPrefix(merged.path), finalValue);
ctx.renderedValues.set(stripStructuredRootPrefix(merged.path), rendered);
}
return { field: displayField };
}
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ export interface DisplayField {
/** Value to show in the UI for this field. */
value: string;

/**
* For iterated array elements whose descriptor defines a `separator`:
* the separator text with `{index}` substituted (e.g. "Recipient 0").
* Per ERC-7730, wallets MUST display it before this field when present.
*/
separator?: string;

/**
* Present when format is "calldata". The value property holds the full
* embedded calldata as a hex string (including any prepended selector).
Expand Down
25 changes: 17 additions & 8 deletions test/erc7730-test-cases/example-array-iteration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ describe("example-array-iteration.json — distribute", () => {
const recipient0 = group.fields[0];
assert(!isFieldGroup(recipient0));
expect(recipient0.label).toBe("Recipients");
expect(recipient0.value).toBe(`Recipient 0 ${RECIPIENT_1_NAME}`);
expect(recipient0.value).toBe(RECIPIENT_1_NAME);
expect(recipient0.separator).toBe("Recipient 0");
expect(recipient0.fieldType).toBe("address");
expect(recipient0.format).toBe("addressName");
expect(recipient0.rawAddress).toBe(checksumRecipient1);
Expand All @@ -162,6 +163,7 @@ describe("example-array-iteration.json — distribute", () => {
assert(!isFieldGroup(percentage0));
expect(percentage0.label).toBe("Percentages");
expect(percentage0.value).toBe("99.01%");
expect(percentage0.separator).toBeUndefined();
expect(percentage0.fieldType).toBe("uint");
expect(percentage0.format).toBe("unit");
expect(percentage0.rawAddress).toBeUndefined();
Expand All @@ -173,7 +175,8 @@ describe("example-array-iteration.json — distribute", () => {
const recipient1 = group.fields[2];
assert(!isFieldGroup(recipient1));
expect(recipient1.label).toBe("Recipients");
expect(recipient1.value).toBe(`Recipient 1 ${RECIPIENT_2_NAME}`);
expect(recipient1.value).toBe(RECIPIENT_2_NAME);
expect(recipient1.separator).toBe("Recipient 1");
expect(recipient1.fieldType).toBe("address");
expect(recipient1.format).toBe("addressName");
expect(recipient1.rawAddress).toBe(checksumRecipient2);
Expand All @@ -185,6 +188,7 @@ describe("example-array-iteration.json — distribute", () => {
assert(!isFieldGroup(percentage1));
expect(percentage1.label).toBe("Percentages");
expect(percentage1.value).toBe("0.99%");
expect(percentage1.separator).toBeUndefined();
expect(percentage1.fieldType).toBe("uint");
expect(percentage1.format).toBe("unit");
expect(percentage1.rawAddress).toBeUndefined();
Expand All @@ -206,7 +210,7 @@ describe("example-array-iteration.json — distribute", () => {

// interpolatedIntent expands array paths with " and " joining
expect(result.interpolatedIntent).toBe(
`Distribute fees 99.01% and 0.99% among recipients Recipient 0 ${RECIPIENT_1_NAME} and Recipient 1 ${RECIPIENT_2_NAME}`,
`Distribute fees 99.01% and 0.99% among recipients ${RECIPIENT_1_NAME} and ${RECIPIENT_2_NAME}`,
);
expect(result.warnings).toBeUndefined();
});
Expand Down Expand Up @@ -482,7 +486,8 @@ describe("example-array-iteration.json — batchExecute", () => {
const recipient = group.fields[0];
assert(!isFieldGroup(recipient));
expect(recipient.label).toBe("Recipients");
expect(recipient.value).toBe(`Recipient 0 ${recipientName}`);
expect(recipient.value).toBe(recipientName);
expect(recipient.separator).toBe("Recipient 0");
expect(recipient.fieldType).toBe("address");
expect(recipient.format).toBe("addressName");
expect(recipient.rawAddress).toBe(
Expand All @@ -496,6 +501,7 @@ describe("example-array-iteration.json — batchExecute", () => {
assert(!isFieldGroup(percentage));
expect(percentage.label).toBe("Percentages");
expect(percentage.value).toBe("100%");
expect(percentage.separator).toBeUndefined();
expect(percentage.fieldType).toBe("uint");
expect(percentage.format).toBe("unit");
expect(percentage.rawAddress).toBeUndefined();
Expand All @@ -516,7 +522,7 @@ describe("example-array-iteration.json — batchExecute", () => {
expect(nested.rawCalldataFallback).toBeUndefined();

expect(nested.interpolatedIntent).toBe(
`Distribute fees 100% among recipients Recipient 0 ${recipientName}`,
`Distribute fees 100% among recipients ${recipientName}`,
);
expect(nested.warnings).toBeUndefined();
}
Expand Down Expand Up @@ -546,7 +552,8 @@ describe("example-array-iteration.json — batchExecute", () => {
const calldataField = group.fields[0];
assert(!isFieldGroup(calldataField));
expect(calldataField.label).toBe("Nested Calls");
expect(calldataField.value).toBe(`Transaction 0 0x${INNER_DISTRIBUTE_1}`);
expect(calldataField.value).toBe(`0x${INNER_DISTRIBUTE_1}`);
expect(calldataField.separator).toBe("Transaction 0");
expect(calldataField.fieldType).toBe("bytes");
expect(calldataField.format).toBe("calldata");
expect(calldataField.rawAddress).toBeUndefined();
Expand Down Expand Up @@ -609,7 +616,8 @@ describe("example-array-iteration.json — batchExecute", () => {
const field0 = group.fields[0];
assert(!isFieldGroup(field0));
expect(field0.label).toBe("Nested Calls");
expect(field0.value).toBe(`Transaction 0 0x${INNER_DISTRIBUTE_1}`);
expect(field0.value).toBe(`0x${INNER_DISTRIBUTE_1}`);
expect(field0.separator).toBe("Transaction 0");
expect(field0.fieldType).toBe("bytes");
expect(field0.format).toBe("calldata");
expect(field0.rawAddress).toBeUndefined();
Expand All @@ -631,7 +639,8 @@ describe("example-array-iteration.json — batchExecute", () => {
const field1 = group.fields[1];
assert(!isFieldGroup(field1));
expect(field1.label).toBe("Nested Calls");
expect(field1.value).toBe(`Transaction 1 0x${INNER_DISTRIBUTE_2}`);
expect(field1.value).toBe(`0x${INNER_DISTRIBUTE_2}`);
expect(field1.separator).toBe("Transaction 1");
expect(field1.fieldType).toBe("bytes");
expect(field1.format).toBe("calldata");
expect(field1.rawAddress).toBeUndefined();
Expand Down
10 changes: 7 additions & 3 deletions test/fields.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ describe("applyFieldFormats", () => {
});

describe("separator handling", () => {
it("prepends separator with interpolated {index} to array elements", async () => {
it("exposes the separator as its own property and keeps rendered values clean", async () => {
const format: DescriptorFormatSpec = {
fields: [
{
Expand Down Expand Up @@ -483,8 +483,12 @@ describe("applyFieldFormats", () => {
assert(!("warnings" in result));
const group = result.fields[0];
assert(isFieldGroup(group));
expect(group.fields[0].value).toBe("Item 0 10");
expect(group.fields[1].value).toBe("Item 1 20");
expect(group.fields[0].value).toBe("10");
expect(group.fields[0].separator).toBe("Item 0");
expect(group.fields[1].value).toBe("20");
expect(group.fields[1].separator).toBe("Item 1");
expect(result.renderedValues.get("vals.[]")).toBe("10 and 20");
expect(result.renderedValues.get("vals")).toBe("10 and 20");
});
});

Expand Down
15 changes: 8 additions & 7 deletions test/registry-cases/ocarina-trade/ocarina-trade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,8 @@ describe("Ocarina OTCRegistry TipAuthorization", () => {

expect(result.intent).toBe("Authorize tip");
// The child array path {tips.[].amount} joins the rendered values of
// all tip amounts. The separator prefix is currently part of the
// rendered values.
expect(result.interpolatedIntent).toBe(
"Authorize Tip 0 1 USDC and Tip 1 2 ETH tip",
);
// all tip amounts. Separators are not part of the rendered values.
expect(result.interpolatedIntent).toBe("Authorize 1 USDC and 2 ETH tip");

assert(result.fields);
// The top-level `tips.[]` field and `orderHash` are never visible.
Expand All @@ -175,7 +172,8 @@ describe("Ocarina OTCRegistry TipAuthorization", () => {

const tip0Amount = tipsGroup.fields[0];
expect(tip0Amount.label).toBe("Tip amount");
expect(tip0Amount.value).toBe("Tip 0 1 USDC");
expect(tip0Amount.value).toBe("1 USDC");
expect(tip0Amount.separator).toBe("Tip 0");
expect(tip0Amount.fieldType).toBe("uint");
expect(tip0Amount.format).toBe("tokenAmount");
expect(tip0Amount.tokenAddress).toBe(USDC_ADDRESS);
Expand All @@ -186,6 +184,7 @@ describe("Ocarina OTCRegistry TipAuthorization", () => {
const tip0Recipient = tipsGroup.fields[1];
expect(tip0Recipient.label).toBe("Tip to");
expect(tip0Recipient.value).toBe("alice.eth");
expect(tip0Recipient.separator).toBeUndefined();
expect(tip0Recipient.fieldType).toBe("address");
expect(tip0Recipient.format).toBe("addressName");
expect(tip0Recipient.rawAddress).toBe(RECIPIENT_A);
Expand All @@ -195,7 +194,8 @@ describe("Ocarina OTCRegistry TipAuthorization", () => {

const tip1Amount = tipsGroup.fields[2];
expect(tip1Amount.label).toBe("Tip amount");
expect(tip1Amount.value).toBe("Tip 1 2 ETH");
expect(tip1Amount.value).toBe("2 ETH");
expect(tip1Amount.separator).toBe("Tip 1");
expect(tip1Amount.fieldType).toBe("uint");
expect(tip1Amount.format).toBe("tokenAmount");
expect(tip1Amount.tokenAddress).toBe(NATIVE_ADDRESS);
Expand All @@ -206,6 +206,7 @@ describe("Ocarina OTCRegistry TipAuthorization", () => {
const tip1Recipient = tipsGroup.fields[3];
expect(tip1Recipient.label).toBe("Tip to");
expect(tip1Recipient.value).toBe("bob.eth");
expect(tip1Recipient.separator).toBeUndefined();
expect(tip1Recipient.fieldType).toBe("address");
expect(tip1Recipient.format).toBe("addressName");
expect(tip1Recipient.rawAddress).toBe(RECIPIENT_B);
Expand Down
Loading