diff --git a/README.md b/README.md index d8e623d..1dd8494 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ const result = await client.get('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984') console.log(result.metadata.name) // "Uniswap" console.log(result.metadata.description) // ... -console.log(result.metadata.functions) // { "delegate(address)": { title: "...", ... }, ... } +console.log(result.metadata.actions) // { "delegate": { function: "delegate(address)", title: "...", ... }, ... } console.log(result.abi) // full ABI from Sourcify (composite for diamonds) ``` @@ -43,7 +43,7 @@ The SDK fetches metadata from four sources in parallel, then merges them with in | Medium | **contractURI** | On-chain ERC-7572 fields: name, symbol, description, image, links | | Highest | **Repository** | Curated JSON from the [evmnow/contract-metadata](https://github.com/evmnow/contract-metadata) repo — full control over every field | -Higher-priority sources override lower ones. Record sections (`functions`, `events`, `errors`, `messages`, `groups`) are shallow-merged per key, so a repository entry can add a `title` to a function while keeping the NatSpec `description` from Sourcify. +Higher-priority sources override lower ones. Record sections (`actions`, `events`, `errors`, `messages`, `groups`) are shallow-merged per key, so a repository entry can add a `title` to an action while keeping the NatSpec `description` from Sourcify. ## Configuration @@ -145,7 +145,7 @@ interface DiamondResolution { facets: FacetInfo[] // address + selectors, plus ABI / NatSpec when Sourcify is enabled compositeAbi?: unknown[] // deduped ABI across every facet natspec?: NatSpec // merged userdoc/devdoc across facets - metadataLayer?: Partial // functions/events/errors ready to merge + metadataLayer?: Partial // actions/events/errors ready to merge } ``` @@ -193,7 +193,7 @@ interface ContractMetadataDocument { audits?: AuditReference[] theme?: Theme groups?: Record - functions?: Record + actions?: Record events?: Record errors?: Record messages?: Record @@ -201,7 +201,7 @@ interface ContractMetadataDocument { } ``` -Function metadata includes fields like `title`, `description`, `intent`, `warning`, `params` (with types, labels, validation, autofill), `examples`, and more. See `src/types.ts` for the full type definitions. +Action metadata includes a required `function` field (the ABI function the action invokes, by name/signature/selector) plus optional `title`, `description`, `intent`, `warning`, `params` (with types, labels, validation, autofill, `hidden`, `disabled`), `examples`, and more. Multiple actions may target the same ABI function as variants (`approve`, `approve-max`, `revoke`). See `src/types.ts` for full type definitions, and `resolveActions(abi, doc)` to turn a metadata document into a ready-to-render list of `ResolvedAction` entries. ## Includes (interfaces) @@ -223,7 +223,7 @@ When the `diamond` source is enabled and an `rpc` is configured, the SDK detects - `result.facets` — one entry per facet with its address, selectors, filtered ABI, and NatSpec - `result.abi` — composite ABI across the main diamond + every facet (first-occurrence wins, deduped by selector for functions and by signature for events/errors) - `result.natspec` — `userdoc` / `devdoc` merged across the diamond and its facets, main doc taking priority -- `result.metadata.functions` / `events` / `errors` — NatSpec-derived sections from every facet layered in at lowest priority, so curated repo/contractURI/main-Sourcify fields still win +- `result.metadata.actions` / `events` / `errors` — NatSpec-derived sections from every facet layered in at lowest priority, so curated repo/contractURI/main-Sourcify fields still win Facets are fetched directly from Sourcify (not recursively through `client.get`), which guards against facets that themselves look like diamonds. Setting `sources.sourcify: false` skips per-facet Sourcify traffic as well — the facets list still contains addresses and selectors, but `abi` and `natspec` are omitted. diff --git a/src/actions.ts b/src/actions.ts new file mode 100644 index 0000000..9946870 --- /dev/null +++ b/src/actions.ts @@ -0,0 +1,249 @@ +import { canonicalSignature, computeSelector } from '@1001-digital/proxies' +import type { ActionMeta, ContractMetadataDocument } from './types' + +export interface AbiParam { + name?: string + type: string + components?: AbiParam[] +} + +export interface AbiFunction { + type: 'function' + name: string + inputs: AbiParam[] + outputs?: AbiParam[] + stateMutability?: 'pure' | 'view' | 'nonpayable' | 'payable' +} + +export interface ResolvedAction { + /** Free-form action identifier (authored key or synthesized default id). */ + id: string + /** The ABI function entry this action invokes. */ + abi: AbiFunction + /** 4-byte selector, lowercase. Stable handle for calldata routing. */ + selector: `0x${string}` + /** Canonical signature, e.g. "approve(address,uint256)". */ + signature: string + /** The action metadata — authored or synthesized from the ABI. */ + meta: ActionMeta + /** True when the action was synthesized from the ABI (no authored entry). */ + synthesized: boolean + /** True when another action in the result shares this selector. */ + isVariant: boolean +} + +export type ActionIssueCode = + | 'unresolved-function' + | 'ambiguous-overload' + | 'hidden-without-autofill' + | 'disabled-without-autofill' + | 'unknown-related' + | 'hidden-and-disabled' + +export interface ActionResolutionIssue { + id: string + code: ActionIssueCode + message: string +} + +export interface ActionResolutionResult { + actions: ResolvedAction[] + issues: ActionResolutionIssue[] +} + +const SELECTOR_RE = /^0x[0-9a-f]{8}$/i +const SIGNATURE_RE = /^[a-zA-Z_][a-zA-Z0-9_]*\(.*\)$/ + +function isAbiFunction(item: unknown): item is AbiFunction { + if (typeof item !== 'object' || item === null) return false + const entry = item as { type?: unknown; name?: unknown } + return entry.type === 'function' && typeof entry.name === 'string' +} + +function extractAbiFunctions(abi: readonly unknown[]): AbiFunction[] { + const out: AbiFunction[] = [] + for (const entry of abi) { + if (isAbiFunction(entry)) { + out.push({ + ...entry, + inputs: (entry.inputs ?? []) as AbiParam[], + }) + } + } + return out +} + +function overloadSlug(fn: AbiFunction): string { + const types = (fn.inputs ?? []).map(i => i.type).join('-') + return types ? `${fn.name}-${types}` : fn.name +} + +/** + * Resolve the list of user-facing actions for a contract given its ABI and + * merged metadata document. + * + * - Every ABI function yields a synthesized default action unless an authored + * action with the same canonical id resolves to the same selector. + * - Authored actions referencing the ABI function by name, signature, or + * selector appear alongside defaults (as variants when their id differs). + * - `issues` surfaces non-fatal problems: unresolved refs, ambiguous overloads, + * param flags missing autofill, and unknown `related` references. + */ +export function resolveActions( + abi: readonly unknown[], + doc: Partial, +): ActionResolutionResult { + const fns = extractAbiFunctions(abi) + const byName = new Map() + const bySignature = new Map() + const bySelector = new Map() + const sigByFn = new Map() + const selByFn = new Map() + + for (const fn of fns) { + const sig = canonicalSignature(fn) + const sel = computeSelector(sig).toLowerCase() as `0x${string}` + sigByFn.set(fn, sig) + selByFn.set(fn, sel) + bySignature.set(sig, fn) + bySelector.set(sel, fn) + const list = byName.get(fn.name) ?? [] + list.push(fn) + byName.set(fn.name, list) + } + + const issues: ActionResolutionIssue[] = [] + const emitted: ResolvedAction[] = [] + const authoredIdsBySelector = new Map>() + + const authored = doc.actions ?? {} + + for (const [id, meta] of Object.entries(authored)) { + // When `function` is omitted, fall back to the action id — so the common + // 1:1 case (`"approve": { title: "..." }`) needs no explicit reference. + const ref = meta.function ?? id + let target: AbiFunction | undefined + + if (SELECTOR_RE.test(ref)) { + target = bySelector.get(ref.toLowerCase()) + } else if (SIGNATURE_RE.test(ref)) { + target = bySignature.get(ref) + } else { + const matches = byName.get(ref) ?? [] + if (matches.length === 1) { + target = matches[0] + } else if (matches.length > 1) { + issues.push({ + id, + code: 'ambiguous-overload', + message: `action "${id}" references overloaded function "${ref}" — use a canonical signature (e.g. "${canonicalSignature(matches[0]!)}")`, + }) + continue + } + } + + if (!target) { + issues.push({ + id, + code: 'unresolved-function', + message: `action "${id}" references function "${ref}" which does not exist in the ABI`, + }) + continue + } + + const sig = sigByFn.get(target)! + const sel = selByFn.get(target)! + const set = authoredIdsBySelector.get(sel) ?? new Set() + set.add(id) + authoredIdsBySelector.set(sel, set) + + emitted.push({ + id, + abi: target, + selector: sel, + signature: sig, + meta, + synthesized: false, + isVariant: false, + }) + } + + const nameCount = new Map() + for (const fn of fns) { + nameCount.set(fn.name, (nameCount.get(fn.name) ?? 0) + 1) + } + + for (const fn of fns) { + const sig = sigByFn.get(fn)! + const sel = selByFn.get(fn)! + const defaultId = (nameCount.get(fn.name) ?? 0) > 1 ? overloadSlug(fn) : fn.name + const authoredForSelector = authoredIdsBySelector.get(sel) + + if (authoredForSelector?.has(defaultId)) continue + + emitted.push({ + id: defaultId, + abi: fn, + selector: sel, + signature: sig, + meta: { function: fn.name }, + synthesized: true, + isVariant: false, + }) + } + + const bySelGroup = new Map() + for (const action of emitted) { + const list = bySelGroup.get(action.selector) ?? [] + list.push(action) + bySelGroup.set(action.selector, list) + } + for (const list of bySelGroup.values()) { + if (list.length > 1) { + for (const a of list) a.isVariant = true + } + } + + for (const action of emitted) { + const params = action.meta.params ?? {} + for (const [pKey, p] of Object.entries(params)) { + if (!p) continue + if (p.hidden && p.autofill === undefined) { + issues.push({ + id: action.id, + code: 'hidden-without-autofill', + message: `action "${action.id}" param "${pKey}" is hidden but has no autofill`, + }) + } + if (p.disabled && p.autofill === undefined) { + issues.push({ + id: action.id, + code: 'disabled-without-autofill', + message: `action "${action.id}" param "${pKey}" is disabled but has no autofill`, + }) + } + if (p.hidden && p.disabled) { + issues.push({ + id: action.id, + code: 'hidden-and-disabled', + message: `action "${action.id}" param "${pKey}" sets both hidden and disabled — these are mutually exclusive`, + }) + } + } + } + + const ids = new Set(emitted.map(a => a.id)) + for (const action of emitted) { + for (const ref of action.meta.related ?? []) { + if (!ids.has(ref)) { + issues.push({ + id: action.id, + code: 'unknown-related', + message: `action "${action.id}" references unknown related action "${ref}"`, + }) + } + } + } + + return { actions: emitted, issues } +} diff --git a/src/index.ts b/src/index.ts index 7541557..359d357 100644 --- a/src/index.ts +++ b/src/index.ts @@ -347,6 +347,17 @@ export type { MetadataSource, } from './errors' +// Action resolution +export { resolveActions } from './actions' +export type { + AbiFunction, + AbiParam, + ResolvedAction, + ActionResolutionIssue, + ActionResolutionResult, + ActionIssueCode, +} from './actions' + // Types export type { ContractMetadataDocument, @@ -369,7 +380,7 @@ export type { Link, AuditReference, Group, - FunctionMeta, + ActionMeta, EventMeta, ErrorMeta, MessageMeta, @@ -378,5 +389,5 @@ export type { Autofill, ValidationRule, ParamPreview, - FunctionExample, + ActionExample, } from './types' diff --git a/src/merge.ts b/src/merge.ts index f79cf02..e809ee5 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -1,7 +1,7 @@ import type { ContractMetadataDocument } from './types' // Top-level keys that contain Record and should merge per-key -const RECORD_SECTIONS = ['groups', 'functions', 'events', 'errors', 'messages'] as const +const RECORD_SECTIONS = ['groups', 'actions', 'events', 'errors', 'messages'] as const type RecordSection = typeof RECORD_SECTIONS[number] diff --git a/src/sources/sourcify.ts b/src/sources/sourcify.ts index b8eb32d..f2e409d 100644 --- a/src/sources/sourcify.ts +++ b/src/sources/sourcify.ts @@ -3,7 +3,7 @@ import type { SourcifyUserDoc, SourcifyDevDoc } from '@1001-digital/natspec' import type { ContractMetadataDocument, SourcifyResult, - FunctionMeta, + ActionMeta, EventMeta, ErrorMeta, } from '../types' @@ -97,8 +97,16 @@ export async function fetchSourcifyWithStatus( Object.entries(data.sources).map(([path, src]) => [path, src.content]), ) } + // Convert NatSpec-derived `functions` (keyed by name/signature) into the + // `actions` shape: each action's identifier is the original key, and a + // `function` field references the same ABI entry. This is a 1:1 mapping — + // NatSpec has no notion of variants, so every derived action is a default. if (metadata.functions && Object.keys(metadata.functions).length > 0) { - result.functions = metadata.functions as Record + const actions: Record = {} + for (const [key, fn] of Object.entries(metadata.functions)) { + actions[key] = { function: key, ...(fn as Omit) } + } + result.actions = actions } if (metadata.events && Object.keys(metadata.events).length > 0) { result.events = metadata.events as Record @@ -114,14 +122,14 @@ export async function fetchSourcifyWithStatus( } /** - * Extract the metadata-doc layer (functions/events/errors) from a SourcifyResult. + * Extract the metadata-doc layer (actions/events/errors) from a SourcifyResult. * Returns null when the SourcifyResult has no NatSpec-derived sections. */ export function buildSourcifyLayer( src: SourcifyResult, ): Partial | null { const layer: Partial = {} - if (src.functions) layer.functions = src.functions + if (src.actions) layer.actions = src.actions if (src.events) layer.events = src.events if (src.errors) layer.errors = src.errors return Object.keys(layer).length > 0 ? layer : null diff --git a/src/types.ts b/src/types.ts index 0d55168..2c68183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,7 +22,7 @@ export interface ContractMetadataDocument { audits?: AuditReference[] theme?: Theme groups?: Record - functions?: Record + actions?: Record events?: Record errors?: Record messages?: Record @@ -62,7 +62,17 @@ export interface Group { order: number } -export interface FunctionMeta { +export interface ActionMeta { + /** + * Reference to the ABI function this action invokes. Accepts a bare name + * (e.g. "approve"), a full Solidity signature for overloaded functions + * (e.g. "approve(address,uint256)"), or a 4-byte selector (e.g. "0x095ea7b3"). + * + * Optional — when omitted, the action's id (its key in the `actions` object) + * is used as the reference. Variants whose id differs from the underlying + * function name (e.g. `revoke` invoking `approve`) MUST set this explicitly. + */ + function?: string order?: number title?: string description?: string @@ -70,11 +80,16 @@ export interface FunctionMeta { group?: string warning?: string featured?: boolean + /** + * Hide this action from the default UI. Also used to suppress an + * ABI-synthesized default action when only authored variants should render. + */ hidden?: boolean stateMutability?: 'view' | 'pure' | 'nonpayable' | 'payable' params?: Record returns?: Record - examples?: FunctionExample[] + examples?: ActionExample[] + /** Identifiers of related actions (keys in the top-level `actions` object). */ related?: string[] deprecated?: string [key: `_${string}`]: unknown @@ -113,6 +128,21 @@ export interface ParamMeta { autofill?: Autofill validation?: ValidationRule preview?: ParamPreview + /** + * When true, do not render an input for this parameter. The `autofill` + * value is injected at call time. REQUIRES `autofill`. + * + * Note: this is the input-side hidden flag — orthogonal to the display-side + * `type: "hidden"` semantic type, which controls whether a value is rendered + * in read contexts. + */ + hidden?: boolean + /** + * When true, render the input but make it non-editable. Displays the + * autofilled value for transparency. REQUIRES `autofill`. Mutually + * exclusive with `hidden: true`. + */ + disabled?: boolean [key: `_${string}`]: unknown } @@ -171,7 +201,7 @@ export interface ParamPreview { image?: string } -export interface FunctionExample { +export interface ActionExample { label: string params: Record } @@ -279,7 +309,7 @@ export interface SourcifyResult { devdoc?: Record sources?: Record deployedBytecode?: string - functions?: Record + actions?: Record events?: Record errors?: Record } diff --git a/test/actions.test.ts b/test/actions.test.ts new file mode 100644 index 0000000..57268dc --- /dev/null +++ b/test/actions.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest' +import { resolveActions } from '../src/actions' +import type { ContractMetadataDocument } from '../src/types' + +const APPROVE_ABI = { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', +} + +const TRANSFER_ABI = { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', +} + +const BALANCE_OF_ABI = { + type: 'function', + name: 'balanceOf', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', +} + +// approve(address,uint256) selector +const APPROVE_SELECTOR = '0x095ea7b3' + +describe('resolveActions', () => { + it('synthesizes a default action for every ABI function when no metadata', () => { + const { actions, issues } = resolveActions([APPROVE_ABI, TRANSFER_ABI, BALANCE_OF_ABI], {}) + expect(issues).toHaveLength(0) + expect(actions).toHaveLength(3) + expect(actions.map(a => a.id).sort()).toEqual(['approve', 'balanceOf', 'transfer']) + for (const a of actions) { + expect(a.synthesized).toBe(true) + expect(a.isVariant).toBe(false) + expect(a.meta.function).toBe(a.abi.name) + } + }) + + it('disambiguates overloads with name-types slug for synthesized defaults', () => { + const abi = [ + { + type: 'function', + name: 'transfer', + inputs: [{ type: 'address' }, { type: 'uint256' }], + }, + { + type: 'function', + name: 'transfer', + inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }], + }, + ] + const { actions } = resolveActions(abi, {}) + expect(actions.map(a => a.id).sort()).toEqual([ + 'transfer-address-uint256', + 'transfer-address-uint256-bytes', + ]) + }) + + it('merges authored action with matching id into default (suppresses synthesis)', () => { + const doc: Partial = { + actions: { + approve: { + function: 'approve', + title: 'Approve', + description: 'Approve tokens', + }, + }, + } + const { actions } = resolveActions([APPROVE_ABI], doc) + expect(actions).toHaveLength(1) + expect(actions[0].synthesized).toBe(false) + expect(actions[0].meta.title).toBe('Approve') + }) + + it('emits variant alongside default when authored id differs from canonical', () => { + const doc: Partial = { + actions: { + revoke: { + function: 'approve', + title: 'Revoke Approval', + params: { + amount: { + autofill: { type: 'constant', value: '0' }, + hidden: true, + }, + }, + }, + }, + } + const { actions } = resolveActions([APPROVE_ABI], doc) + expect(actions).toHaveLength(2) + const ids = actions.map(a => a.id).sort() + expect(ids).toEqual(['approve', 'revoke']) + // Both share the same selector → both flagged as variants + for (const a of actions) { + expect(a.selector).toBe(APPROVE_SELECTOR) + expect(a.isVariant).toBe(true) + } + const revoke = actions.find(a => a.id === 'revoke')! + expect(revoke.synthesized).toBe(false) + const defaultApprove = actions.find(a => a.id === 'approve')! + expect(defaultApprove.synthesized).toBe(true) + }) + + it('resolves authored action by canonical signature', () => { + const doc: Partial = { + actions: { + approve: { + function: 'approve(address,uint256)', + title: 'Approve by signature', + }, + }, + } + const { actions, issues } = resolveActions([APPROVE_ABI], doc) + expect(issues).toHaveLength(0) + expect(actions).toHaveLength(1) + expect(actions[0].meta.title).toBe('Approve by signature') + }) + + it('resolves authored action by 4-byte selector', () => { + const doc: Partial = { + actions: { + approve: { + function: APPROVE_SELECTOR, + title: 'Approve by selector', + }, + }, + } + const { actions, issues } = resolveActions([APPROVE_ABI], doc) + expect(issues).toHaveLength(0) + expect(actions).toHaveLength(1) + expect(actions[0].meta.title).toBe('Approve by selector') + }) + + it('emits ambiguous-overload issue for bare name referencing overloaded function', () => { + const abi = [ + { type: 'function', name: 'transfer', inputs: [{ type: 'address' }, { type: 'uint256' }] }, + { type: 'function', name: 'transfer', inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }] }, + ] + const doc: Partial = { + actions: { + myTransfer: { function: 'transfer', title: 'Ambiguous' }, + }, + } + const { actions, issues } = resolveActions(abi, doc) + expect(issues).toHaveLength(1) + expect(issues[0].code).toBe('ambiguous-overload') + expect(issues[0].id).toBe('myTransfer') + // The variant is skipped; synthesized defaults still render for both overloads + expect(actions.map(a => a.id).sort()).toEqual([ + 'transfer-address-uint256', + 'transfer-address-uint256-bytes', + ]) + }) + + it('emits unresolved-function issue when ref does not match ABI', () => { + const doc: Partial = { + actions: { + ghost: { function: 'nonexistent', title: 'Ghost' }, + }, + } + const { actions, issues } = resolveActions([APPROVE_ABI], doc) + expect(issues).toHaveLength(1) + expect(issues[0].code).toBe('unresolved-function') + expect(issues[0].id).toBe('ghost') + // Unresolved action is skipped; synthesized default still renders + expect(actions).toHaveLength(1) + expect(actions[0].id).toBe('approve') + }) + + it('emits hidden-without-autofill issue', () => { + const doc: Partial = { + actions: { + broken: { + function: 'approve', + params: { amount: { hidden: true } }, + }, + }, + } + const { issues } = resolveActions([APPROVE_ABI], doc) + const hiddenIssue = issues.find(i => i.code === 'hidden-without-autofill') + expect(hiddenIssue).toBeTruthy() + expect(hiddenIssue!.id).toBe('broken') + }) + + it('emits disabled-without-autofill issue', () => { + const doc: Partial = { + actions: { + broken: { + function: 'approve', + params: { amount: { disabled: true } }, + }, + }, + } + const { issues } = resolveActions([APPROVE_ABI], doc) + const issue = issues.find(i => i.code === 'disabled-without-autofill') + expect(issue).toBeTruthy() + }) + + it('emits hidden-and-disabled issue when both are set', () => { + const doc: Partial = { + actions: { + conflict: { + function: 'approve', + params: { + amount: { + autofill: { type: 'constant', value: '0' }, + hidden: true, + disabled: true, + }, + }, + }, + }, + } + const { issues } = resolveActions([APPROVE_ABI], doc) + const issue = issues.find(i => i.code === 'hidden-and-disabled') + expect(issue).toBeTruthy() + }) + + it('emits unknown-related issue when related references a missing action id', () => { + const doc: Partial = { + actions: { + approve: { + function: 'approve', + related: ['does-not-exist'], + }, + }, + } + const { issues } = resolveActions([APPROVE_ABI], doc) + const issue = issues.find(i => i.code === 'unknown-related') + expect(issue).toBeTruthy() + expect(issue!.id).toBe('approve') + }) + + it('computes correct selector and signature for synthesized defaults', () => { + const { actions } = resolveActions([APPROVE_ABI], {}) + expect(actions).toHaveLength(1) + expect(actions[0].selector).toBe(APPROVE_SELECTOR) + expect(actions[0].signature).toBe('approve(address,uint256)') + }) + + it('falls back to action id when `function` is omitted', () => { + const doc: Partial = { + actions: { + approve: { + title: 'Approve (no function field)', + description: 'Implicit 1:1 mapping via id', + }, + }, + } + const { actions, issues } = resolveActions([APPROVE_ABI], doc) + expect(issues).toHaveLength(0) + expect(actions).toHaveLength(1) + expect(actions[0].synthesized).toBe(false) + expect(actions[0].meta.title).toBe('Approve (no function field)') + expect(actions[0].selector).toBe(APPROVE_SELECTOR) + }) + + it('omitted function + unknown id → unresolved-function', () => { + const doc: Partial = { + actions: { + notAFunction: { + title: 'Mystery', + }, + }, + } + const { actions, issues } = resolveActions([APPROVE_ABI], doc) + expect(issues).toHaveLength(1) + expect(issues[0].code).toBe('unresolved-function') + // Synthesized default for approve still present + expect(actions).toHaveLength(1) + expect(actions[0].id).toBe('approve') + }) +}) diff --git a/test/index.test.ts b/test/index.test.ts index fbe4c83..488ea05 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -32,8 +32,8 @@ describe('createContractClient', () => { it('resolves from all three sources', async () => { const repoMetadata = { name: 'Wrapped Ether', - functions: { - deposit: { title: 'Wrap ETH', description: 'curated' }, + actions: { + deposit: { function: 'deposit', title: 'Wrap ETH', description: 'curated' }, }, } @@ -107,11 +107,11 @@ describe('createContractClient', () => { // contractURI provides symbol and image expect(result.metadata.symbol).toBe('WETH') expect(result.metadata.image).toBe('https://example.com/weth.png') - // Repository's curated function takes priority - expect(result.metadata.functions?.deposit).toEqual({ title: 'Wrap ETH', description: 'curated' }) - // Sourcify/NatSpec fills in functions not in repo - expect(result.metadata.functions?.withdraw).toBeTruthy() - expect(result.metadata.functions?.withdraw?.description).toBe('Withdraw WETH to ETH') + // Repository's curated action takes priority + expect(result.metadata.actions?.deposit).toEqual({ function: 'deposit', title: 'Wrap ETH', description: 'curated' }) + // Sourcify/NatSpec fills in actions not in repo + expect(result.metadata.actions?.withdraw).toBeTruthy() + expect(result.metadata.actions?.withdraw?.description).toBe('Withdraw WETH to ETH') // ABI from Sourcify expect(result.abi).toEqual([{ type: 'function', name: 'deposit' }]) @@ -560,9 +560,9 @@ describe('createContractClient', () => { const fnNames = (result.abi as any[]).filter(f => f.type === 'function').map(f => f.name).sort() expect(fnNames).toEqual(['balanceOf', 'totalSupply', 'transfer']) - // NatSpec merged across facets → metadata.functions populated for both - expect(result.metadata.functions?.['transfer']).toBeTruthy() - expect(result.metadata.functions?.['totalSupply']).toBeTruthy() + // NatSpec merged across facets → metadata.actions populated for both + expect(result.metadata.actions?.['transfer']).toBeTruthy() + expect(result.metadata.actions?.['totalSupply']).toBeTruthy() }) it('falls back to facets() probe when ERC-165 errors', async () => { diff --git a/test/merge.test.ts b/test/merge.test.ts index 53253bf..9993906 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -32,21 +32,21 @@ describe('merge', () => { it('merges record sections per-key', () => { const low = { - functions: { - transfer: { description: 'from natspec' }, - approve: { description: 'from natspec' }, + actions: { + transfer: { function: 'transfer', description: 'from natspec' }, + approve: { function: 'approve', description: 'from natspec' }, }, } const high = { - functions: { - transfer: { title: 'Transfer', description: 'from repo' }, + actions: { + transfer: { function: 'transfer', title: 'Transfer', description: 'from repo' }, }, } const result = merge(low, high) - expect(result.functions).toEqual({ - transfer: { title: 'Transfer', description: 'from repo' }, - approve: { description: 'from natspec' }, + expect(result.actions).toEqual({ + transfer: { function: 'transfer', title: 'Transfer', description: 'from repo' }, + approve: { function: 'approve', description: 'from natspec' }, }) }) @@ -64,18 +64,18 @@ describe('merge', () => { it('higher priority record key fully replaces lower', () => { const low = { - functions: { - transfer: { description: 'old', title: 'Old Title' }, + actions: { + transfer: { function: 'transfer', description: 'old', title: 'Old Title' }, }, } const high = { - functions: { - transfer: { description: 'new' }, + actions: { + transfer: { function: 'transfer', description: 'new' }, }, } // Per-key replacement, not deep merge - expect(merge(low, high).functions?.transfer).toEqual({ description: 'new' }) + expect(merge(low, high).actions?.transfer).toEqual({ function: 'transfer', description: 'new' }) }) it('array fields use highest priority', () => { @@ -93,23 +93,23 @@ describe('merge', () => { it('merges three layers correctly', () => { const sourcify = { name: 'From Sourcify', - functions: { transfer: { description: 'natspec' } }, + actions: { transfer: { function: 'transfer', description: 'natspec' } }, } const contractUri = { name: 'From Contract', image: 'https://example.com/logo.png', } const repo = { - functions: { - transfer: { title: 'Transfer', description: 'curated' }, - approve: { title: 'Approve' }, + actions: { + transfer: { function: 'transfer', title: 'Transfer', description: 'curated' }, + approve: { function: 'approve', title: 'Approve' }, }, } const result = merge(sourcify, contractUri, repo) expect(result.name).toBe('From Contract') expect(result.image).toBe('https://example.com/logo.png') - expect(result.functions?.transfer).toEqual({ title: 'Transfer', description: 'curated' }) - expect(result.functions?.approve).toEqual({ title: 'Approve' }) + expect(result.actions?.transfer).toEqual({ function: 'transfer', title: 'Transfer', description: 'curated' }) + expect(result.actions?.approve).toEqual({ function: 'approve', title: 'Approve' }) }) }) diff --git a/test/sources/contract-uri.test.ts b/test/sources/contract-uri.test.ts index 54aff44..a3bbe47 100644 --- a/test/sources/contract-uri.test.ts +++ b/test/sources/contract-uri.test.ts @@ -41,8 +41,8 @@ describe('fetchContractURI', () => { const metadata = { name: 'Test', symbol: 'T', - functions: { transfer: {} }, // not an ERC-7572 field - custom: 'value', // not an ERC-7572 field + actions: { transfer: { function: 'transfer' } }, // not an ERC-7572 field + custom: 'value', // not an ERC-7572 field } const dataUri = `data:application/json;base64,${btoa(JSON.stringify(metadata))}` const abiResult = abiEncodeString(dataUri) @@ -55,7 +55,7 @@ describe('fetchContractURI', () => { const result = await fetchContractURI(chainId, address, rpc, fetchFn) expect(result).toEqual({ name: 'Test', symbol: 'T' }) - expect(result).not.toHaveProperty('functions') + expect(result).not.toHaveProperty('actions') expect(result).not.toHaveProperty('custom') }) diff --git a/test/sources/proxy.test.ts b/test/sources/proxy.test.ts index 31568b9..32fff4b 100644 --- a/test/sources/proxy.test.ts +++ b/test/sources/proxy.test.ts @@ -46,7 +46,7 @@ describe('enrichTargets (Sourcify-bound)', () => { const src: SourcifyResult = { abi: [{ type: 'function', name: 'transfer', inputs: [{ type: 'address' }, { type: 'uint256' }] }], userdoc: { methods: { 'transfer(address,uint256)': { notice: 'moves' } } }, - functions: { 'transfer(address,uint256)': { description: 'moves' } }, + actions: { 'transfer(address,uint256)': { function: 'transfer(address,uint256)', description: 'moves' } }, } const sourcifyFetch = vi.fn(async (addr: string) => addr === '0x' + 'aa'.repeat(20) ? src : null, @@ -82,7 +82,7 @@ describe('enrichTargets (Sourcify-bound)', () => { }) describe('composeProxyResolution (metadataLayer)', () => { - it('builds metadataLayer from SourcifyResult.functions/events/errors', () => { + it('builds metadataLayer from SourcifyResult.actions/events/errors', () => { const targets = [ { address: '0x' + 'aa'.repeat(20), @@ -92,13 +92,13 @@ describe('composeProxyResolution (metadataLayer)', () => { ] const sourcifyResults: SourcifyResult[] = [ { - functions: { 'transfer(address,uint256)': { description: 'moves tokens' } }, + actions: { 'transfer(address,uint256)': { function: 'transfer(address,uint256)', description: 'moves tokens' } }, events: { 'Transfer(address,address,uint256)': { description: 'emitted on transfer' } }, }, ] const out = composeProxyResolution(targets, sourcifyResults) - expect(out.metadataLayer?.functions).toBeTruthy() + expect(out.metadataLayer?.actions).toBeTruthy() expect(out.metadataLayer?.events).toBeTruthy() expect(out.compositeAbi).toHaveLength(1) }) @@ -172,7 +172,7 @@ describe('fetchProxy (high-level)', () => { expect(result!.targets).toHaveLength(1) expect(result!.targets[0].abi).toHaveLength(1) expect(result!.compositeAbi).toHaveLength(1) - expect(result!.metadataLayer?.functions).toBeTruthy() + expect(result!.metadataLayer?.actions).toBeTruthy() expect(result!.natspec?.userdoc).toBeTruthy() }) diff --git a/test/sources/repository.test.ts b/test/sources/repository.test.ts index 4078397..509a65c 100644 --- a/test/sources/repository.test.ts +++ b/test/sources/repository.test.ts @@ -14,7 +14,7 @@ describe('fetchRepository', () => { const address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' it('fetches metadata from repository URL', async () => { - const metadata = { name: 'WETH', functions: { deposit: { title: 'Deposit' } } } + const metadata = { name: 'WETH', actions: { deposit: { function: 'deposit', title: 'Deposit' } } } const fetchFn = mockFetch(metadata) const result = await fetchRepository(chainId, address, fetchFn, 'https://repo.test/contracts') diff --git a/test/sources/sourcify.test.ts b/test/sources/sourcify.test.ts index c1c855c..6272a3b 100644 --- a/test/sources/sourcify.test.ts +++ b/test/sources/sourcify.test.ts @@ -34,8 +34,9 @@ describe('fetchSourcify', () => { expect(result).toBeTruthy() expect(result!.abi).toEqual([{ type: 'function', name: 'deposit' }]) - expect(result!.functions?.deposit).toBeTruthy() - expect(result!.functions?.deposit?.description).toBe('Deposit ETH to get WETH') + expect(result!.actions?.deposit).toBeTruthy() + expect(result!.actions?.deposit?.function).toBe('deposit') + expect(result!.actions?.deposit?.description).toBe('Deposit ETH to get WETH') // Raw natspec preserved expect(result!.userdoc).toEqual(response.userdoc) expect(result!.devdoc).toEqual(response.devdoc) @@ -68,7 +69,7 @@ describe('fetchSourcify', () => { const result = await fetchSourcify(chainId, address, fetchFn, 'https://sourcify.test') expect(result).toBeTruthy() expect(result!.abi).toBeTruthy() - expect(result!.functions).toBeUndefined() + expect(result!.actions).toBeUndefined() }) it('constructs URL with base fields by default', async () => {