Skip to main content

Composition

Composition in Lambit means writing reusable functions that return ExprNode predicates. You can keep contracts small by composing branches and policy fragments.

Plain TypeScript functions are still the core composition mechanism. For larger contracts, group those functions into small local helper objects and use method.named(schema, (args) => ...) so nested helper calls stay named instead of threading long positional argument lists through every layer.

import {
contract,
method,
TypeTag,
and,
gte,
eq,
hash160,
checkSig,
sha256,
type ExprNode,
} from '@opcat-labs/lambit';

const AtomicSwap = contract(
'AtomicSwap',
{
recipient: TypeTag.Ripemd160,
refund: TypeTag.Ripemd160,
hashlock: TypeTag.Sha256,
timeout: TypeTag.Int,
},
({ props }) => {
const auth = {
signedBy: (sig: ExprNode, pubkey: ExprNode, owner: ExprNode): ExprNode =>
and(eq(hash160(pubkey), owner), checkSig(sig, pubkey)),
};

const branches = {
redeem: (
args: Readonly<{
sig: ExprNode;
pubkey: ExprNode;
preimage: ExprNode;
}>,
): ExprNode =>
and(
auth.signedBy(args.sig, args.pubkey, props.recipient),
eq(sha256(args.preimage), props.hashlock),
),
refund: (
args: Readonly<{
sig: ExprNode;
pubkey: ExprNode;
locktime: ExprNode;
}>,
): ExprNode =>
and(
gte(args.locktime, props.timeout),
auth.signedBy(args.sig, args.pubkey, props.refund),
),
};

return {
redeem: method.named(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey, preimage: TypeTag.ByteString },
branches.redeem,
),
refund: method.named(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey, locktime: TypeTag.Int },
branches.refund,
),
};
},
);

const swap = AtomicSwap({
recipient: '00112233445566778899aabbccddeeff00112233',
refund: 'ffeeddccbbaa99887766554433221100ffeeddcc',
hashlock: '11'.repeat(32),
timeout: 500000n,
});

console.log(AtomicSwap.name); // AtomicSwap
console.log(Object.keys(swap.methods)); // ['redeem', 'refund']

Recommended: Keep helper layers near the contract body. Small local objects like auth, branches, or transitions make it easy to test or refactor complex contracts without turning the method entrypoints into long positional parameter lists.

Reusable helper libraries with library()

Use library() when the same helper logic should be packaged and reused across contracts without rebuilding raw ExprNode trees by hand. Library definitions carry artifact metadata, and stateful libraries can expose helper methods over a bound state snapshot.

If a library only touches another library inside a returned callback, declare that dependency in options.libraries so artifact metadata still captures the full closure.

import {
add,
and,
checkSig,
contract,
eq,
gt,
hash160,
library,
method,
TypeTag,
type ExprNode,
} from '@opcat-labs/lambit';

const CounterMath = library(
'CounterMath',
{ step: TypeTag.Int32 },
{ count: TypeTag.Int32 },
({ params, state }) => ({
nextCount: () => add(state.count, params.step),
}),
);

const OwnerAuth = library(
'OwnerAuth',
{ owner: TypeTag.Ripemd160 },
({ params }) => ({
authorize: (sig: ExprNode, pubkey: ExprNode) =>
and(eq(hash160(pubkey), params.owner), checkSig(sig, pubkey)),
}),
);

const CounterRules = library(
'CounterRules',
{ owner: TypeTag.Ripemd160, limit: TypeTag.Int32, step: TypeTag.Int32 },
{ count: TypeTag.Int32 },
({ params, state }) => {
const counterMath = CounterMath(
{ step: params.step },
{ count: state.count },
);
// CounterMath is observed immediately, so it does not need options.libraries.

return {
nextCount: () => counterMath.nextCount(),
canAdvance: (nextCount: ExprNode, sig: ExprNode, pubkey: ExprNode) => (
and(
gt(params.limit, nextCount),
OwnerAuth({ owner: params.owner }).authorize(sig, pubkey),
)
),
};
},
// OwnerAuth is only touched inside the returned callback, so declare it here.
{ libraries: [OwnerAuth] },
);

const GuardedCounter = contract(
'GuardedCounter',
{ owner: TypeTag.Ripemd160, limit: TypeTag.Int32, step: TypeTag.Int32 },
{ count: TypeTag.Int32 },
({ props, state }) => ({
advance: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) => {
const rules = CounterRules(
{ owner: props.owner, limit: props.limit, step: props.step },
{ count: state.count },
);
const nextCount = rules.nextCount();
return {
next: { count: nextCount },
check: rules.canAdvance(nextCount, sig, pubkey),
};
},
),
}),
);

const guarded = GuardedCounter({
owner: '00112233445566778899aabbccddeeff00112233',
limit: 10n,
step: 2n,
});
const guardedLibraryNames = guarded.artifact.library.map(({ name }) => name);
const guardedLibraryStateTypes = guarded.artifact.library.map(
({ stateType }) => stateType ?? null,
);

console.log(GuardedCounter.name); // GuardedCounter
console.log(guardedLibraryNames); // ['CounterMath', 'OwnerAuth', 'CounterRules']
console.log(guardedLibraryStateTypes); // ['CounterMathState', null, 'CounterRulesState']

Artifact libraries are emitted in deterministic post-order: dependencies appear before the libraries that use them so the runtime can embed each helper script in dependency order.

When a reusable helper result feeds both next and check, compute it once in the contract method and thread that expression into downstream library helpers. That keeps the generated script from rebuilding the same expression tree twice.

either(), both(), and timelocked()

import {
contract,
method,
TypeTag,
and,
or,
gte,
eq,
hash160,
checkSig,
type ExprNode,
} from '@opcat-labs/lambit';

const either = (left: ExprNode, right: ExprNode): ExprNode => or(left, right);

const both = (first: ExprNode, ...rest: ExprNode[]): ExprNode => and(first, ...rest);

const timelocked = (
locktime: ExprNode,
deadline: ExprNode,
branch: ExprNode,
): ExprNode => both(gte(locktime, deadline), branch);

const Escrow = contract(
'Escrow',
{
alice: TypeTag.Ripemd160,
bob: TypeTag.Ripemd160,
deadline: TypeTag.Int,
},
({ props }) => ({
unlock: method.named(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey, locktime: TypeTag.Int },
({ sig, pubkey, locktime }) =>
either(
both(eq(hash160(pubkey), props.alice), checkSig(sig, pubkey)),
timelocked(
locktime,
props.deadline,
both(eq(hash160(pubkey), props.bob), checkSig(sig, pubkey)),
),
),
),
}),
);

Custom combinators

import {
add,
cond,
eq,
fold,
gt,
lit,
type ExprNode,
} from '@opcat-labs/lambit';

const countPassing = (checks: ExprNode[]): ExprNode =>
fold(checks, lit(0n), (acc, check) =>
add(acc, cond(check, lit(1n), lit(0n))),
);

const atLeast = (min: ExprNode, checks: ExprNode[]): ExprNode =>
gt(add(countPassing(checks), lit(1n)), min);

const isTwoOfThree = (checks: ExprNode[]): ExprNode =>
eq(countPassing(checks), lit(2n));

Composition patterns

PatternShapeUse case
either(a, b)or(a, b)Alternative spend paths.
both(a, b, ...)and(a, b, ...)Multiple mandatory checks.
timelocked(locktime, deadline, branch)and(gte(locktime, deadline), branch)Delayed branch activation.
countPassing([...])fold + cond + addThreshold-style policies.
note

Combinators are regular TypeScript functions. Keep them pure and return ExprNode.

Caution: Avoid empty branch lists in custom combinators when you call and() / or() internally.

What's Next