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