@lifi/composer-sdk
Version:
Public Composer SDK for building and submitting flows
250 lines (226 loc) • 9.03 kB
text/typescript
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 }),
};
};