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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand All @@ -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

Expand Down Expand Up @@ -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<ContractMetadataDocument> // functions/events/errors ready to merge
metadataLayer?: Partial<ContractMetadataDocument> // actions/events/errors ready to merge
}
```

Expand Down Expand Up @@ -193,15 +193,15 @@ interface ContractMetadataDocument {
audits?: AuditReference[]
theme?: Theme
groups?: Record<string, Group>
functions?: Record<string, FunctionMeta>
actions?: Record<string, ActionMeta>
events?: Record<string, EventMeta>
errors?: Record<string, ErrorMeta>
messages?: Record<string, MessageMeta>
[key: `_${string}`]: unknown // extension fields allowed on any underscore-prefixed key
}
```

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)

Expand All @@ -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.

Expand Down
249 changes: 249 additions & 0 deletions src/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ContractMetadataDocument>,
): ActionResolutionResult {
const fns = extractAbiFunctions(abi)
const byName = new Map<string, AbiFunction[]>()
const bySignature = new Map<string, AbiFunction>()
const bySelector = new Map<string, AbiFunction>()
const sigByFn = new Map<AbiFunction, string>()
const selByFn = new Map<AbiFunction, `0x${string}`>()

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<string, Set<string>>()

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<string>()
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<string, number>()
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<string, ResolvedAction[]>()
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 }
}
15 changes: 13 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -369,7 +380,7 @@ export type {
Link,
AuditReference,
Group,
FunctionMeta,
ActionMeta,
EventMeta,
ErrorMeta,
MessageMeta,
Expand All @@ -378,5 +389,5 @@ export type {
Autofill,
ValidationRule,
ParamPreview,
FunctionExample,
ActionExample,
} from './types'
2 changes: 1 addition & 1 deletion src/merge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ContractMetadataDocument } from './types'

// Top-level keys that contain Record<string, object> 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]

Expand Down
16 changes: 12 additions & 4 deletions src/sources/sourcify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { SourcifyUserDoc, SourcifyDevDoc } from '@1001-digital/natspec'
import type {
ContractMetadataDocument,
SourcifyResult,
FunctionMeta,
ActionMeta,
EventMeta,
ErrorMeta,
} from '../types'
Expand Down Expand Up @@ -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<string, FunctionMeta>
const actions: Record<string, ActionMeta> = {}
for (const [key, fn] of Object.entries(metadata.functions)) {
actions[key] = { function: key, ...(fn as Omit<ActionMeta, 'function'>) }
}
result.actions = actions
}
if (metadata.events && Object.keys(metadata.events).length > 0) {
result.events = metadata.events as Record<string, EventMeta>
Expand All @@ -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<ContractMetadataDocument> | null {
const layer: Partial<ContractMetadataDocument> = {}
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
Expand Down
Loading