Basics
Lambit contracts are pure TypeScript functions that build expression trees (ExprNode).
Use contract() to define a named contract, then call that definition to bind props and reach the runtime bridge.
contract()
This example uses the byte-minimal P2PKH form: compose the address check and signature
check directly, and let pubKey2Addr(pubkey) document the address derivation intent.
import {
contract,
method,
TypeTag,
and,
eq,
pubKey2Addr,
checkSig,
} from '@opcat-labs/lambit';
const P2PKH = contract(
'P2PKH',
{ addr: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) => and(eq(pubKey2Addr(pubkey), props.addr), checkSig(sig, pubkey)),
),
}),
);
const p2pkh = P2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
console.log(P2PKH.name); // P2PKH
console.log(p2pkh.artifact.hex); // 76a9<addr>88ac
Wrap the same predicate in assert(...) when you want assertion metadata. That appends
an explicit verify boundary (OP_VERIFY OP_1) after the P2PKH predicate.
import {
contract,
method,
TypeTag,
and,
assert,
eq,
pubKey2Addr,
checkSig,
} from '@opcat-labs/lambit';
const AssertP2PKH = contract(
'AssertP2PKH',
{ addr: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) => assert(and(eq(pubKey2Addr(pubkey), props.addr), checkSig(sig, pubkey))),
),
}),
);
const assertP2pkh = AssertP2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
console.log(assertP2pkh.artifact.hex); // 76a9<addr>88ac6951
contract() parameters
| Parameter | Type | Description |
|---|---|---|
name | string | Required contract name. Must be a valid TypeScript identifier. |
props | Record<string, TypeTag | ReturnType<typeof alias> | ReturnType<typeof struct>> | Constructor schema. These are the arguments you pass when binding the definition, for example P2PKH({ addr }). Each field becomes a prop expression node. |
body / state, body | ({ props, state? }) => Record<string, MethodDescriptor> | Closure that receives the ambient contract env and returns method(schema, fn) or method.named(schema, (args) => ...) declarations. |
Binding props
Calling the definition binds constructor props without a separate helper import.
import {
contract,
method,
TypeTag,
and,
eq,
hash160,
checkSig,
} from '@opcat-labs/lambit';
const P2PKH = contract(
'P2PKH',
{ addr: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) => and(eq(hash160(pubkey), props.addr), checkSig(sig, pubkey)),
),
}),
);
const p2pkh = P2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
console.log(p2pkh.artifact.contract); // P2PKH
console.log(Object.keys(p2pkh.methods)); // ['unlock']
Reusable helpers
Lambit exports standard helpers so you do not have to rebuild common patterns each time.
import {
OpCode,
P2PK,
P2PKH,
contract,
method,
TypeTag,
eq,
fill,
lit,
min,
opcode,
opcodes,
within,
} from '@opcat-labs/lambit';
const p2pkInstance = P2PK({ pubkey: '02'.padEnd(66, '1') });
const p2pkhInstance = P2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
const OpcodePolicy = contract(
'OpcodePolicy',
{},
() => ({
matchesDup: method(
{ actual: TypeTag.OpCodeType },
(actual) => eq(actual, opcode('OP_DUP')),
),
matchesP2pkhPrefix: method(
{ prefix: TypeTag.ByteString },
(prefix) => eq(prefix, opcodes('OP_DUP', 'OP_HASH160')),
),
}),
);
const opcodeBytes = opcodes('OP_DUP', 'OP_HASH160');
const repeatedChecks = fill(eq(OpCode.OP_DUP, opcode('OP_DUP')), 2);
const bounded = within(min(lit(5n), lit(7n)), lit(0n), lit(10n));
console.log(p2pkInstance.artifact.hex); // <pubkey>ac
console.log(p2pkhInstance.artifact.hex); // 76a9<addr>88ac
console.log(opcodeBytes); // { tag: 'literal', value: '76a9' }
console.log(repeatedChecks.length); // 2
console.log(bounded); // { tag: 'call', op: 'within', ... }
console.log(OpcodePolicy.name); // OpcodePolicy
within(value, lower, upper) uses the Bitcoin OP_WITHIN half-open range rule:
lower <= value < upper (the lower bound is included, the upper bound is not).
Props and method params
props are read from the ambient contract closure.
Method parameters are declared by method(schema, fn) for positional args, or
method.named(schema, (args) => ...) when named helper composition reads better.
That same schema is emitted into artifact.abi, preserving each param name and type.
The output is an ABI array with one function entry per method plus a trailing constructor entry.
Schemas may also use alias(name, type) and struct(name, fields) when you need artifact/runtime metadata for shaped values.
Current limit: these custom types are preserved through the IR, artifact, and runtime encoding layers, but the DSL still treats them as opaque values rather than exposing field access inside contract expressions.
The optional struct(..., genericTypes) list is emitted as metadata for artifact consumers; runtime encoding validates the declared field list directly and does not specialize generic parameters.
import { contract, method, TypeTag, add, eq, buildArtifact } from '@opcat-labs/lambit';
const SumPolicy = contract(
'SumPolicy',
{ target: TypeTag.Int },
({ props }) => ({
unlock: method(
{ a: TypeTag.Int, b: TypeTag.Int },
(a, b) => eq(add(a, b), props.target),
),
namedSum: method.named(
{ left: TypeTag.Int, right: TypeTag.Int },
({ left, right }) => eq(add(left, right), props.target),
),
}),
);
const artifact = buildArtifact(SumPolicy);
console.log(artifact.abi);
Expressions and combinators
Use DSL primitives to build boolean predicates.
| Category | Helpers |
|---|---|
| Logic | and, or, not, cond |
| Comparison | eq, neq, gt, gte, lt, lte |
| Arithmetic | add, sub, mul, div, mod, abs, min, max, within |
| Crypto | sha256(...), hash160(...), hash256(...), pubKey2Addr(...), checkSig(...), checkSigVerify(...), checkMultiSig(...), checkMultiSigVerify(...), checkDataSig(...), checkDataSigVerify(...) |
| Bytes | cat, slice, len, intToBytes, bytesToInt, buildP2pkhOutput |
assert(expr) and assert(expr, message) both create explicit verify boundaries.
pubKey2Addr(pubkey) is the named P2PKH address helper. It preserves authored address
intent in IR while lowering to the same OP_HASH160 script surface as hash160(pubkey).
within(value, lower, upper) uses a half-open interval, so within(x, 0n, 10n)
accepts 0n through 9n and rejects 10n.
buildP2pkhOutput(pkh, satoshis) builds the serialized 34-byte P2PKH transaction output
<8-byte amount><1-byte script length 0x19><25-byte P2PKH scriptPubKey> used by
output-covenant commitments. pkh values must be exactly 20 bytes, and satoshis
values must fit the current OP_NUM2BIN 8-byte encoding path:
0 <= satoshis <= 2^63 - 1. The compiled guard uses OP_NUM2BIN 8 for
the upper bound and a byte-level sign-bit check for non-negativity.
| Pattern | Use when |
|---|---|
and(checkSig(...), otherCheck) or and(checkDataSig(...), otherCheck) | You are composing boolean predicates; the compiler can fuse eligible checks to VERIFY opcodes. |
assert(checkSig(...), message) or assert(checkDataSig(...), message) | You want an explicit verify boundary plus assertion metadata in the artifact. |
checkSigVerify(...), checkMultiSigVerify(...), or checkDataSigVerify(...) | The VERIFY opcode is the whole method body, or you want assertion metadata exactly at that VERIFY site. |
import {
contract,
method,
TypeTag,
assert,
checkDataSig,
} from '@opcat-labs/lambit';
const DataSig = contract(
'DataSig',
{ pubKey: TypeTag.PubKey },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, message: TypeTag.ByteString },
(sig, message) => assert(checkDataSig(sig, message, props.pubKey)),
),
}),
);
const dataSig = DataSig({ pubKey: '02'.padEnd(66, '1') });
console.log(dataSig.artifact.hex.includes('ba69')); // true
Build-time helpers
fold, map, every, and some run at build time and return new ExprNode trees.
range, mapRange, foldRange, everyRange, and someRange do the same for explicit numeric loops.
import { contract, method, fold, lit, add, eq, TypeTag } from '@opcat-labs/lambit';
const SumThree = contract(
'SumThree',
{ expected: TypeTag.Int },
({ props }) => ({
unlock: method(
{ x: TypeTag.Int, y: TypeTag.Int, z: TypeTag.Int },
(x, y, z) => {
const total = fold([x, y, z], lit(0n), (acc, elem) => add(acc, elem));
return eq(total, props.expected);
},
),
}),
);
import {
contract,
method,
TypeTag,
lit,
add,
eq,
foldRange,
} from '@opcat-labs/lambit';
const BiasedSum = contract(
'BiasedSum',
{ expected: TypeTag.Int },
({ props }) => ({
unlock: method(
{ x: TypeTag.Int, y: TypeTag.Int, z: TypeTag.Int },
(x, y, z) => {
const values = [x, y, z];
// Produces 1-based weights 1..values.length; values.length + 1 is the exclusive end.
// i remains the zero-based JavaScript subscript; weightExpr is the 1-based literal.
const total = foldRange(1, values.length + 1, lit(0n), (acc, weightExpr, i) =>
add(acc, add(values[i]!, weightExpr)),
);
return eq(total, props.expected);
},
),
}),
);
Loop helpers: Range callbacks receive both the literal range value and the zero-based JavaScript iteration counter. In the count form (
foldRange(values.length, ...)), that literal is0..count-1; in the bounded form (foldRange(1, values.length + 1, ...)), it isstart..end-1. Use the literal expression inside returned IR trees and the iteration number for local array subscripts.Caution:
and(...)andor(...)require at least one argument and throw on empty input. Compile-time range helpers also reject non-integer bounds, reject inverted bounds (start > end), accept signed bounds such asrange(-2, 1), and cap unrolling at 128 iterations per helper call. If you are migrating from older code, keep the contract name incontract('Name', ...)instead of assigningdef.namelater.For the full supported model, including
cond(...),branches(...), dispatch structure, and loop-sizing guidance, see Built-in Functions.