UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

250 lines (226 loc) 9.03 kB
import type { Ref } from '@lifi/compose-spec'; import { type ParseAbiItem, parseAbiItem } from 'abitype'; import type { AbiTypeToOutputKind, TypedGuard } from '../types.js'; import type { AnyBindable, CallArgs } from './FlowBuilderCore.js'; import type { Bindable, OutputHandle } from './handles.js'; import { handleToRef } from './handles.js'; /** * Type-level extraction of named bind keys from a function signature string. * When `TSig` is a string literal matching a Solidity function signature, * resolves to a record whose keys are the parameter names and values are * `Bindable<kind>` where the kind is derived from each parameter's Solidity * type via `AbiTypeToOutputKind`. For recognised `SolType` parameters * (e.g. `uint256`, `address`, `uint128`) the bind slot is precisely typed; * for other Solidity types (e.g. tuples) the slot accepts any scalar handle. * Falls back to `Record<string, AnyBindable>` for non-literal strings. */ export type SignatureBind<TSig extends string> = string extends TSig ? Record<string, AnyBindable> : ParseAbiItem<TSig> extends { type: 'function'; inputs: infer I extends readonly { name: string; type: string }[]; } ? { readonly [P in I[number] as P['name']]: Bindable< AbiTypeToOutputKind<P['type']> >; } : Record<string, AnyBindable>; // --------------------------------------------------------------------------- // Return-type inference from function signatures // --------------------------------------------------------------------------- /** * Describes the output type(s) of a parsed function signature as a * human-readable string for inclusion in type-level error messages. */ type DescribeOutputs<O extends readonly { type: string }[]> = O extends readonly [{ type: infer T extends string }] ? T : 'multiple values'; /** * A string-literal type that surfaces a clear error when the user provides a * function signature whose return type is not supported by core.call. * The message is visible in IDE tooltips and compiler diagnostics. */ type UnsupportedReturnTypeError<TLabel extends string, TSig extends string> = ParseAbiItem<TSig> extends { type: 'function'; outputs: infer O extends readonly { type: string }[]; } ? `[TypeError] ${TLabel}: return type '${DescribeOutputs<O>}' is not supported — only 'uint256' or void signatures are allowed` : `[TypeError] ${TLabel}: unable to parse return type from signature`; /** * Conditional output type for `core.call` based on the function signature's * return type: * * - `returns (uint256)` → `{ result: OutputHandle }` * - No returns clause → `{ result: undefined }` * - Anything else → `{ result: "[TypeError] ..." }` (compile-time error) * * When `TSig` is a non-literal `string` (e.g. a variable, not a string * constant), the type falls back to `{ result: OutputHandle | undefined }` * so that both void and uint256 call sites remain assignable. */ export type CoreCallResult<TSig extends string> = string extends TSig ? { readonly result: OutputHandle<'uint256'> | undefined } : ParseAbiItem<TSig> extends { type: 'function'; outputs: readonly [] } ? { readonly result: undefined } : ParseAbiItem<TSig> extends { type: 'function'; outputs: readonly [{ type: 'uint256' }]; } ? { readonly result: OutputHandle<'uint256'> } : { readonly result: UnsupportedReturnTypeError<'core.call', TSig> }; /** * Conditional output type for `core.staticCall`. staticCall always reads a * value, so void signatures are also rejected: * * - `returns (uint256)` → `{ result: OutputHandle }` * - No returns / other → `{ result: "[TypeError] ..." }` (compile-time error) */ export type StaticCallResult<TSig extends string> = string extends TSig ? { readonly result: OutputHandle<'uint256'> } : ParseAbiItem<TSig> extends { type: 'function'; outputs: readonly [{ type: 'uint256' }]; } ? { readonly result: OutputHandle<'uint256'> } : ParseAbiItem<TSig> extends { type: 'function'; outputs: readonly [] } ? { readonly result: `[TypeError] core.staticCall: function has no return value — staticCall requires a 'returns (uint256)' signature`; } : { readonly result: UnsupportedReturnTypeError<'core.staticCall', TSig>; }; /** * Parses a Solidity function signature and returns parameter names in order. * Delegates to abitype's runtime `parseAbiItem` — the same parser that powers * the compile-time `ParseAbiItem<TSig>` type — so runtime and type-level * parsing can never diverge. * * @throws if the signature is not a valid function signature or a parameter is unnamed */ export const parseFunctionParams = (functionSignature: string): string[] => { const parsed = parseAbiItem(functionSignature); if (parsed.type !== 'function') { throw new Error( `core.call: expected a function signature, got "${parsed.type}": "${functionSignature}"`, ); } return parsed.inputs.map((input, index) => { if (!input.name) { throw new Error( `core.call: parameter at index ${index} in signature has no name. All parameters must be named for named binds to work.`, ); } return input.name; }); }; /** * Converts any `AnyBindable` to a `Ref` (`{ $ref: string }`). * Handles carry a `_tag` property and are converted via `handleToRef`; * `Ref` objects pass through as-is. */ export const toBindRef = (bindable: AnyBindable): Ref => { if ('_tag' in bindable) return handleToRef(bindable); return bindable; }; export interface BuildCallWireFormatInput { readonly resource?: AnyBindable; readonly bind: Record<string, AnyBindable>; readonly config: Record<string, unknown>; readonly guards?: readonly TypedGuard[]; } export interface CallWireResult { readonly op: 'core.call' | 'core.invoke'; readonly bind: Record<string, Ref>; readonly config: Record<string, unknown>; readonly guards?: readonly TypedGuard[]; } /** * Parses a function signature from `config`, validates bind keys against it, * and converts named binds to positional `$ref` args. */ const resolvePositionalArgs = ( label: string, bind: Record<string, AnyBindable>, config: Record<string, unknown>, ): Ref[] => { const functionSignature = config.functionSignature; if (typeof functionSignature !== 'string') { throw new Error( `${label}: config.functionSignature is required and must be a string`, ); } const paramNames = parseFunctionParams(functionSignature); const paramSet = new Set(paramNames); const bindKeys = Object.keys(bind); const bindSet = new Set(bindKeys); const missing = paramNames.filter((n) => !bindSet.has(n)); const extra = bindKeys.filter((k) => !paramSet.has(k)); if (missing.length > 0 || extra.length > 0) { throw new Error( `${label}: bind keys [${bindKeys.join( ', ', )}] do not match signature parameters [${paramNames.join( ', ', )}]. Missing: ${missing.join(', ') || '(none)'}. Extra: ${ extra.join(', ') || '(none)' }.`, ); } return paramNames.map((name) => { const bindable = bind[name]; if (bindable === undefined) { throw new Error( `${label}: bind value for parameter "${name}" is undefined`, ); } return toBindRef(bindable); }); }; /** * Transforms the user-facing `resource` + named `bind` API into the wire * format that the backend expects: * - With resource: `core.call` with `bind: { input: <resourceRef> }` * - Without resource: `core.invoke` with `bind: {}` * - `config: { ...userConfig, args: [positional refs...] }` */ export const buildCallWireFormat = ( args: BuildCallWireFormatInput, ): CallWireResult => { const positionalArgs = resolvePositionalArgs( 'core.call', args.bind, args.config, ); const hasResource = args.resource !== undefined; return { op: hasResource ? 'core.call' : 'core.invoke', bind: hasResource ? { input: toBindRef(args.resource) } : {}, config: { ...args.config, args: positionalArgs }, ...(args.guards && args.guards.length > 0 && { guards: args.guards }), }; }; export interface BuildStaticCallWireFormatInput { readonly bind: Record<string, AnyBindable>; readonly config: Record<string, unknown>; readonly guards?: readonly TypedGuard[]; } /** * Transforms the user-facing named `bind` API into the wire format that the * backend expects for `core.staticCall`. Same as `buildCallWireFormat` but * with no `resource` field — staticCall has no resource inputs. */ export const buildStaticCallWireFormat = ( args: BuildStaticCallWireFormatInput, ): CallArgs => { const positionalArgs = resolvePositionalArgs( 'core.staticCall', args.bind, args.config, ); return { bind: {}, config: { ...args.config, args: positionalArgs }, ...(args.guards && args.guards.length > 0 && { guards: args.guards }), }; };