UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

271 lines (246 loc) 8.2 kB
import type { AppliedGuard, Call, Flow, FlowInput, Ref, Resource, SolType, } from '@lifi/compose-spec'; import type { InputDecl, InputSchema, OutputKind, TypedGuard, TypedRef, } from '../types.js'; import type { InputHandle, OutputHandle, ResourceInputHandle, } from './handles.js'; import { handleToRef } from './handles.js'; import { ref } from './raw.js'; /** * Arguments for calling an operation node in a flow. */ export interface CallArgs { /** Maps parameter names to input/output handles or raw `$ref` pointers. */ readonly bind: Record<string, AnyBindable>; /** Optional operation-specific configuration. */ readonly config?: object; /** Optional guards (e.g. slippage checks) applied to this operation's outputs. */ readonly guards?: readonly TypedGuard[]; } /** * Any value that can be bound to an operation parameter: an input handle, * an output handle from a previous operation, or a typed `$ref` pointer. * * Use `raw.ref<T>()` to create a typed ref from a raw path string. */ export type AnyBindable = InputHandle | OutputHandle | TypedRef; const toRef = (bindable: AnyBindable): Ref => { if ('_tag' in bindable) return handleToRef(bindable); return bindable; }; /** * Provides references to runtime context values available inside a flow. * Access via `builder.context`. */ export interface ContextAccessor { /** A `$ref` pointing to the transaction sender (signer) address. */ readonly sender: TypedRef<'address'>; /** A `$ref` pointing to the on-chain execution (proxy) address. */ readonly executionAddress: TypedRef<'address'>; } type InputHandleOf<D extends InputDecl> = D extends Resource ? ResourceInputHandle : D extends SolType ? InputHandle<D> : InputHandle; /** * A mapped type that converts an input schema into typed handles. * Resource inputs produce {@link ResourceInputHandle}; scalar inputs produce {@link InputHandle}. * * @typeParam T - The flow's input schema. */ export type InputHandles<T extends InputSchema> = { readonly [K in keyof T]: InputHandleOf<T[K]>; }; /** * Options for creating a new flow builder. * * @typeParam T - The flow's input schema. */ export interface FlowOptions<T extends InputSchema> { /** Optional human-readable name for the flow. Defaults to a random UUID. */ readonly name?: string; /** Input declarations mapping names to resource or scalar types. */ readonly inputs: T; } /** * A Flow document branded with its input schema for type-safe request building. * * The `__inputs` field is a phantom type — it exists only at compile time for * TypeScript inference and is never present at runtime. */ export type TypedFlow<T extends InputSchema = InputSchema> = Flow & { readonly __inputs?: T; }; /** * Core flow builder interface providing low-level access to flow construction. * * For most use cases, prefer the {@link FlowBuilder} type returned by `sdk.flow()` * which adds generated operation methods and a `compile` method. * * @typeParam T - The flow's input schema. */ export interface FlowBuilderCore<T extends InputSchema = InputSchema> { /** Runtime context references (sender address, execution address). */ readonly context: ContextAccessor; /** Typed handles for each declared flow input, used to bind inputs to operations. */ readonly inputs: InputHandles<T>; /** * Appends an untyped operation node to the flow. This is an escape hatch * for operations not yet covered by the generated methods. * * Use `raw.ref<T>()` to reference this node's outputs in subsequent typed * operations. * * @param id - Unique node identifier within the flow. * @param op - The operation name (e.g. `"custom.vaultQuery"`). * @param args - Bind map, config, and optional guards for the node. * @throws Error if a node with the same `id` already exists in the flow. */ readonly untypedOp: ( id: string, op: string, args: { readonly bind: Record<string, Ref>; readonly config: Record<string, unknown>; readonly guards?: readonly AppliedGuard[]; }, ) => void; /** Serialises the builder state into a {@link TypedFlow} document. */ readonly build: () => TypedFlow<T>; } /** Maps output port names to their output kind for compile-time and runtime validation. */ export type OutputSpec = Record<string, OutputKind>; /** Internal interface exposed to bindGeneratedOps — not part of the public API. */ export interface FlowBuilderInternal< T extends InputSchema = InputSchema, > extends FlowBuilderCore<T> { readonly call: <O extends OutputSpec>( nodeId: string, op: string, args: CallArgs, outputs: O, ) => { readonly [K in keyof O & string]: OutputHandle<O[K]> }; } const isResource = (decl: InputDecl): decl is Resource => typeof decl === 'object' && decl !== null && 'kind' in decl; /** * Creates a new flow builder targeting the given chain. * * @param chainId - The EVM chain ID (e.g. `1` for Ethereum mainnet). * @param options - Flow configuration including input declarations. * @returns A {@link FlowBuilderInternal} instance. * @internal */ export const createFlowBuilderCore = <T extends InputSchema>( chainId: number, options: FlowOptions<T>, ): FlowBuilderInternal<T> => { const id = options.name ?? crypto.randomUUID(); const flowInputs: FlowInput[] = []; const nodes: Call[] = []; const nodeIds = new Set<string>(); const inputHandles = {} as Record<string, InputHandle | ResourceInputHandle>; for (const [name, decl] of Object.entries(options.inputs)) { if (isResource(decl)) { if (decl.chainId !== chainId) { throw new Error( `Input "${name}" has chainId ${decl.chainId} but flow targets chain ${chainId}`, ); } flowInputs.push({ name, resource: decl }); inputHandles[name] = { _tag: 'input', inputName: name, resource: decl }; } else { flowInputs.push({ name, type: decl }); inputHandles[name] = { _tag: 'input', inputName: name }; } } const call = <O extends OutputSpec>( nodeId: string, op: string, args: CallArgs, outputs: O, ): { readonly [K in keyof O & string]: OutputHandle<O[K]> } => { if (nodeIds.has(nodeId)) throw new Error(`Duplicate node id: "${nodeId}"`); nodeIds.add(nodeId); const bind: Record<string, Ref> = {}; for (const [key, val] of Object.entries(args.bind)) { bind[key] = toRef(val); } const node: Call = { id: nodeId, op, bind, config: (args.config ?? {}) as Record<string, unknown>, ...(args.guards && args.guards.length > 0 && { guards: args.guards }), }; nodes.push(node); type Result = { readonly [K in keyof O & string]: OutputHandle<O[K]> }; return new Proxy({} as Result, { get: (_target, prop): OutputHandle | undefined => { if (typeof prop !== 'string' || prop === 'then') return undefined; if (!(prop in outputs)) { throw new Error( `Op "${op}" has no output port "${prop}". Valid ports: ${Object.keys( outputs, ).join(', ')}`, ); } return { _tag: 'output', nodeId, portName: prop }; }, }); }; const untypedOp = ( nodeId: string, op: string, args: { readonly bind: Record<string, Ref>; readonly config: Record<string, unknown>; readonly guards?: readonly AppliedGuard[]; }, ): void => { if (nodeIds.has(nodeId)) throw new Error(`Duplicate node id: "${nodeId}"`); nodeIds.add(nodeId); const node: Call = { id: nodeId, op, bind: args.bind, config: args.config, ...(args.guards && args.guards.length > 0 && { guards: args.guards }), }; nodes.push(node); }; const build = (): TypedFlow<T> => ({ version: 1, id, chainId, inputs: [...flowInputs], nodes: [...nodes], }); const context: ContextAccessor = { sender: ref<'address'>('context.sender'), executionAddress: ref<'address'>('context.executionAddress'), }; return { context, inputs: inputHandles as InputHandles<T>, call, untypedOp, build, }; };