@opcat-labs/lambit
v0.1.0
Published
Lambit — a functional TypeScript-to-Bitcoin-Script language and compiler
Readme
Lambit
Functional TypeScript to Bitcoin Script compiler and native runtime.
Lambit lets you define contracts as TypeScript expression trees, compile them into artifact-style JSON (ABI + locking script hex), and exercise deploy/call flows through a provider-driven runtime.
Status
This repository is a 0.1.0 / 0.1.x developer release focused on the functional DSL, compiler pipeline, artifact output, scaffolded project workflow, and provider-driven native runtime.
The public docs focus on contract authoring, local testing, deployment, debugging, and examples. Internal release and parity tracking stays out of the user-facing documentation.
Install
npm installCLI
Use the Lambit CLI for the common compile, artifact, scaffold, and test workflows:
lambit compile ./contracts/p2pkh.ts --out-dir artifacts
lambit artifact ./contracts/p2pkh.ts --export P2PKH --output artifacts/P2PKH.json
lambit scaffold ./my-lambit-app
lambit test
lambit test --testnetcompileloads compatible contract exports from a TS/JS module and writes one artifact JSON file per export.artifactprints or writes a single artifact JSON payload, which is useful for inspection or scripting.scaffoldgenerates a starter project with a contract example and ready-to-run package scripts.testwraps this repo's Mocha setup when run inside this checkout. Outside the package, it lets Mocha discover the current project's config unless you pass--configor--no-config.testforwards extra arguments to Mocha, including--help; uselambit help testfor the CLI command options andlambit test --helpfor Mocha help.- For deterministic project-local test runs outside this repo, prefer
lambit test --config <path>orlambit test --no-config; both paths load TypeScript tests directly when the selected Mocha config does not already loadtsx, and--no-configalso disables Mocha config discovery. compileandartifactexecute the target module. Only run them on trusted code.- Loading
.tscontract modules uses the optional packagedtsxdependency, or a compatibletsxversion from the current project when one is installed. If optional dependencies were omitted, installtsxbefore using TypeScript modules with the CLI.
Scaffold a New Project
Generate a starter app from the template shipped in the package:
lambit scaffold ./my-lambit-appRun lambit scaffold --help for the scaffold command options.
In this source checkout, npm run scaffold -- ./my-lambit-app runs the same scaffold implementation after npm install.
By default, the generated package.json depends on the current repo version range, for example ^0.1.0.
When testing against a local tarball or branch build, override that dependency explicitly:
npm run build
npm pack
npm run scaffold -- ./my-lambit-app --lambit file:/absolute/path/to/opcat-labs-lambit-0.1.0.tgzOnly pass trusted package specifiers to --lambit, because the scaffolded project will install exactly what you provide.
Generated projects are private by default to prevent accidental publication. Pass --public if you want the generated package.json to be publishable:
npm run scaffold -- ./my-lambit-lib --publicThe scaffolded project includes:
src/
contracts/
counter.ts
index.ts
package.json
tsconfig.json
README.md
.gitignoreRun npm run contracts:compile in the scaffolded project to generate artifacts/Counter.json.
Defaults:
- ESM TypeScript output in
dist/ - strict TypeScript enabled
private: trueinpackage.jsonunless--publicis passed- a minimal stateful
Countercontract example build,contracts:compile,contracts:artifact, andstartscripts ready to run afternpm install
Build
npm run buildTest
lambit testnpm test remains available and runs lambit test, including the scaffold integration suite in this source checkout.
In this source checkout, npm scripts execute src/cli.ts through tsx; run npm run build before validating the published dist/cli.js binary.
For the guarded OpcatLayer testnet suite, run lambit test --testnet or npm run test:testnet.
Live Testnet E2E
- Guard: the live suite skips unless
LAMBIT_RUN_TESTNET_E2E=1is set. - Wallet: fund a dedicated
TESTNET_WIF, then runnpm run print:testnet-addressto confirm the funding address. - Endpoint: set
TESTNET_API_BASE_URLonly when you need a custom OpcatLayer testnet API. - Coverage: the suite deploys and spends funded-provider-compatible native artifacts, including HTLC and RollupStats checks. AtomicSwap's stateless
assertOutputspaths are guarded as funded-provider rejections until an unfunded exact-output provider exists. - State: the Counter flow leaves the latest contract UTXO live on testnet for later inspection.
- Focused AtomicSwap guard: run
npm run test:testnet:atomicswap. It validates live configuration, locktime shape, and the funded-provider rejection before broadcast; it does not deploy AtomicSwap while the current live provider is funded. - GitHub Actions runs the same
npm run test:testnettarget from.github/workflows/testnet-e2e.yml, but only on trusted contexts: pushes tomainand manualworkflow_dispatch. - The workflow uses the protected GitHub environment
opcat-testnet-e2e. Store the funded wallet as theTESTNET_WIFenvironment secret there, and setTESTNET_API_BASE_URLas an environment variable only if you need a non-default provider endpoint. - The guarded workflow sets
LAMBIT_RUN_TESTNET_E2E=1and is serialized with a single concurrency group andcancel-in-progress: false, so two runs do not try to spend the same funded wallet at once and later runs stay queued behind any stuck or long-running job. - Re-run intentionally from the Actions tab by choosing
Guarded Testnet E2EandRun workflow, or by using GitHub's built-in re-run controls on a completed run. - CI logs print the funding address, API base URL, deploy txids, spend txids, and expected rejection context so failures are diagnosable from the run log.
lambit test/npm testdo not run the live OpcatLayer testnet suite. Runlambit test --testnetornpm run test:testnetexplicitly when you want live testnet coverage.- When
LAMBIT_RUN_TESTNET_E2E=1,TESTNET_WIFmust be set to a funded testnet WIF or the command fails fast with an actionable error. getWifAddress(TESTNET_WIF, 'testnet')returns the base58 funding address for that WIF.- The same funded WIF pays testnet deploy/call fees and signs the P2PKH spend path.
- Set
TESTNET_API_BASE_URLto target a non-default OpcatLayer API endpoint; the logged endpoint is normalized with/apiappended when runtime normalization would do so, and query parameters are omitted from logs so credentials are not printed. - The guarded live target covers P2PKH deploy+spend, Counter deploy+increase, chained Counter state rejection, AuctionProof deploy+bid, HTLC claim/refund/cooperative paths, and RollupStats multi-field state transitions via Lambit only.
- The Counter flow intentionally leaves the latest contract UTXO live on testnet, so repeated runs consume funded balance unless you spend or rotate that wallet.
npm run test:testnet:auctionis the focused live repro for the single Auction deploy +bid()proof test.- The focused Auction command uses a Node runner to set
LAMBIT_RUN_TESTNET_E2E=1for its Mocha run and exits non-zero whenTESTNET_WIFis unset or the@auction-prooftest tag matches zero tests; that is intentional so a skipped live suite cannot look like an auditable proof run. - The focused Auction runner waits synchronously for Mocha and enforces a 7-minute parent-process timeout; a timeout or kill signal is reported as a failed proof run.
- The focused Auction proof fixture in this repo is intentionally bid-only; settlement/close semantics are not modeled in this proof path.
- Avoid putting raw WIFs in shell history. Prefer
read -sor another local secret-loading flow before running the suite.
npm run build
read -s TESTNET_WIF
TESTNET_WIF="$TESTNET_WIF" npm run print:testnet-address
LAMBIT_RUN_TESTNET_E2E=1 TESTNET_WIF="$TESTNET_WIF" lambit test --testnet
LAMBIT_RUN_TESTNET_E2E=1 TESTNET_WIF="$TESTNET_WIF" npm run test:testnet
LAMBIT_RUN_TESTNET_E2E=1 TESTNET_WIF="$TESTNET_WIF" npm run test:testnet:auction
LAMBIT_RUN_TESTNET_E2E=1 TESTNET_WIF="$TESTNET_WIF" TESTNET_AUDIT_LOG=docs/logs/atomicswap.ndjson npm run test:testnet:atomicswap
unset TESTNET_WIFUse How to Deploy & Call for native deploy/call, funding, endpoint overrides, and testnet gotchas before repeated live runs.
The focused Auction repro prints machine-readable proof lines like:
[testnet-proof] auction.deploy txid=... vout=0 highestBid=1 highestBidder=...
[testnet-proof] auction.bid txid=... spentTxid=... successorVout=0 highestBid=5 highestBidder=...Capture those lines in your run log so the live proof remains auditable after the funded testnet spend.
Documentation Map
Repository docs:
- Overview
- Installation
- Bitcoin Basics
- How to Write a Contract: Basics
- How to Write a Contract: Built-in Functions
- How to Write a Contract: Script Context
- How to Write a Contract: Stateful Contracts
- How to Test a Contract
- How to Deploy & Call
- How to Debug a Contract
- Frontend Integration
- How to Publish a Contract
- How to Verify a Contract
- Examples
- TicTacToe Tutorial
- Ethereum Developers
- FAQ
- Advanced Composition
- Output Covenants
- Tokens
README sections:
- Quick Start (stateless)
- Authoring Flow
- Runtime Flow
- Migration Notes
- Artifact Output
- Debug and Snapshot Workflow
Concepts
- Contracts are authored as expression trees (
ExprNode) rather than raw script opcodes. contract(name, props, body)andcontract(name, props, state, body)return named, callable contract definitions.method(schema, fn)andmethod.named(schema, (args) => ...)make the method schema the ABI source of truth.- Declared constructor props and method params are preserved in
artifact.abiwith their names and types. - Calling a contract def binds props and returns a
BoundContractwithartifact,methods, andinit(). buildArtifact(def)andcreateInstance(...)still exist as lower-level helpers, but the bound-contract flow is the documented default.
Quick Start: Stateless Contract
The standard P2PKH helper provides the byte-minimal address check plus signature
check. Use pubKey2Addr(pubkey) when authoring custom P2PKH-style predicates; it
documents address intent while lowering to the same OP_HASH160 script surface as
hash160(pubkey).
import {
P2PKH,
} from '@opcat-labs/lambit';
const p2pkh = P2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
console.log(P2PKH.name); // P2PKH
console.log(p2pkh.artifact.hex); // 76a9<addr>88acWhen you want assertion metadata, wrap the predicate in assert(...). That adds an
explicit verify boundary (OP_VERIFY OP_1), so the script is slightly longer.
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>88ac6951Authoring Flow: Define, Bind, Transition, Init
Stateful methods return:
next: expressions for every declared state fieldcheck: spending condition expression
import {
contract,
method,
TypeTag,
add,
and,
checkSig,
gte,
sub,
} from '@opcat-labs/lambit';
const Vault = contract(
'Vault',
{ owner: TypeTag.PubKey },
{ balance: TypeTag.Int },
({ props, state }) => ({
withdraw: method(
{ amount: TypeTag.Int, sig: TypeTag.Sig },
(amount, sig) => ({
next: { balance: sub(state.balance, amount) },
check: and(checkSig(sig, props.owner), gte(state.balance, amount)),
}),
),
deposit: method(
{ amount: TypeTag.Int },
(amount) => ({
// Pedagogical example: anyone can deposit. Add auth for production flows.
next: { balance: add(state.balance, amount) },
}),
),
}),
);
const vault = Vault({ owner: '02'.padEnd(66, '1') });
const s0 = { balance: 100n };
const s1 = vault.methods.withdraw.next(s0, { amount: 30n });
const s2 = vault.methods.deposit.next(s1, { amount: 50n });
const instance = vault.init(s0);
console.log(vault.artifact.contract); // Vault
console.log({ s0, s1, s2 });
console.log(instance.state); // { balance: 100n }Runtime Flow: Deploy and Call
This is a compact offline smoke test for the runtime APIs. The example uses the fluent provider-driven helpers exposed on bound and deployed contracts. Use How to Deploy & Call as the canonical guide for realistic funding, deploy/call, testnet setup, and operational gotchas.
import {
contract,
method,
TypeTag,
add,
and,
checkSig,
createMemoryProvider,
createSigner,
gte,
sub,
} from '@opcat-labs/lambit';
const Vault = contract(
'Vault',
{ owner: TypeTag.PubKey },
{ balance: TypeTag.Int },
({ props, state }) => ({
withdraw: method(
{ amount: TypeTag.Int, sig: TypeTag.Sig },
(amount, sig) => ({
next: { balance: sub(state.balance, amount) },
check: and(checkSig(sig, props.owner), gte(state.balance, amount)),
}),
),
deposit: method(
{ amount: TypeTag.Int },
(amount) => ({
// Pedagogical example: anyone can deposit. Add auth for production flows.
next: { balance: add(state.balance, amount) },
}),
),
}),
);
const signer = createSigner();
const owner = await signer.getPublicKey();
const provider = createMemoryProvider();
const vault = Vault({ owner });
const deployed = await vault.deploy({ balance: 100n }, {
provider,
satoshis: 100_000n,
});
const spend = await deployed.methods.withdraw.call(
{ amount: 30n },
{
provider,
signer,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: owner }),
}),
nextState: vault.methods.withdraw.next({ balance: 100n }, { amount: 30n }),
},
);
console.log(spend.nextInstance?.state); // { balance: 70n }API Overview
DSL Entry Points
contract(name, props, body)andcontract(name, props, state, body)return callable contract definitionsmethod(schema, fn)for positional args, ormethod.named(schema, (args) => ...)/method.named(schema, ({ key }) => ...)for helper-heavy composition and schema-aware named params such as fixed-array helpers- calling a contract def binds props:
const vault = Vault({ owner }) - bound methods expose pure transitions with
vault.methods.<name>.next(state, args?) vault.init(state?)bridges the authoring API into a runtimeInstance
import { FixedArray, TypeTag, contract, eq, method } from '@opcat-labs/lambit';
const Cells = FixedArray(TypeTag.Int, 2);
const Grid = contract(
'Grid',
{},
() => ({
unlock: method.named(
{ board: Cells, index: TypeTag.Int, value: TypeTag.Int },
({ board, index, value }) => eq(board.at(index), value),
),
}),
);IR and Typing
TypeTagdefines constructor and state field typesalias(name, type)andstruct(name, fields)preserve user-defined type metadata in the IR and emitted artifacts;struct(..., genericTypes)is metadata-only today and is not used to specialize runtime encodingExprNodeis the expression-tree IR shape used by the DSL/compiler
DSL Primitives
Examples:
- Logic:
and,or,not,cond - Comparison:
eq,neq,gt,gte,lt,lte - Arithmetic:
add,sub,mul,div,mod - Crypto:
sha256(...),hash160(...),hash256(...),pubKey2Addr(...),checkSig(...),checkSigVerify(...),checkMultiSig(...),checkMultiSigVerify(...),checkDataSig(...),checkDataSigVerify(...) - Bytes:
cat,slice,len,intToBytes,bytesToInt - Build-time arrays:
fold,map,every,some
Signature and Address Helpers
pubKey2Addr(pubkey) is the named address helper for P2PKH-style checks. It preserves
authored address intent in IR while lowering to the same OP_HASH160 script surface as
hash160(pubkey).
checkDataSig(sig, message, pubkey) emits OP_CHECKDATASIG.
| 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. |
assert(expr) and assert(expr, message) both compile to an explicit verify boundary
and record assertion metadata in artifacts.
import {
contract,
method,
TypeTag,
and,
assert,
checkDataSig,
checkSig,
eq,
pubKey2Addr,
} from '@opcat-labs/lambit';
const DataSignedP2PKH = contract(
'DataSignedP2PKH',
{ addr: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, dataSig: TypeTag.Sig, message: TypeTag.ByteString, pubkey: TypeTag.PubKey },
(sig, dataSig, message, pubkey) => assert(
and(
eq(pubKey2Addr(pubkey), props.addr),
checkSig(sig, pubkey),
checkDataSig(dataSig, message, pubkey),
),
),
),
}),
);
const dataSigned = DataSignedP2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
console.log(dataSigned.artifact.contract); // DataSignedP2PKH
console.log(dataSigned.artifact.hex); // 76a9<addr>88537a5179ac537a537a537aba9a6951Standard Helpers
- reusable contract defs:
P2PK,P2PKH - reusable predicates:
p2pkh - opcode-byte literals:
opcode,opcodes
Compiler Helpers
buildArtifact(def, options?)compiles aContractDefinto artifact JSONdecodeArtifactHex(artifact.hex, options?)decodes opcode/placeholder token streams for quick diffssnapshotArtifact(artifact, options?)normalizes artifact metadata into a stable snapshot objectformatArtifactDebug(artifact, options?)renders a readable multiline debug viewstackCompileandstackCompileWithStackcompile expression trees into script chunksstateHashandbuildDataOutputare the public state-output helpers used by stateful artifact/runtime flowschunksToHexconverts script chunks into final hex output
Runtime Helpers
vault.init(state)creates anInstancecreateInstanceremains available when you already have an artifact and constructor argsvault.deploy(...)andvault.prepareDeploy(...)handle provider-backed deployment from a bound contractvault.prepareDeploy(...)returns a deferred deployment handle with the initialized instance, provider, satoshis, and a laterdeploy()methoddeployed.methods.<name>.call(...)and.prepareCall(...)handle provider-backed spends from a deployed instancesnapshotInstance(instance)converts runtime values into JSON-safe debug snapshotsformatInstanceDebug(instance)renders a readable multiline runtime dumpcreateMemoryProviderimplements theProviderinterface for fully offline tests, plus an opt-in realistic funding mode that materializes wallet inputs/change; realistic contract outputs must provideoutput.instancecreateSignerprovides a Bitcoin-format secp256k1 signer for examples and local coverage
Runtime Migration Notes
- Prefer
bound.deploy(...),bound.prepareDeploy(...),deployed.methods.<name>.call(...), anddeployed.methods.<name>.prepareCall(...); legacy globaldeployInstance(...),call(...), andprepareCall(...)remain as deprecated compatibility exports. - Signature-bearing fluent method calls accept
signer+invokedirectly in the options object, sopsbt.getSig(...)no longer forces a fallback to a global callback helper. - Callback and method-style invocation both reject missing or unexpected named args before transaction building. Partial arg maps that used to fail later at script execution now throw at call preparation time.
Migration Notes
- Old
stateful(props, state, body)orstateful(name, props, state, body)call sites should move tocontract('Name', props, state, body). - Old post-hoc naming like
def.name = 'Vault'should move into the firstcontract(...)argument so artifact metadata, bound contracts, and docs all agree on one source of truth. - Old prop-binding helpers become direct calls on the definition:
const vault = Vault({ owner }). - If you already have an artifact-only workflow,
buildArtifact(def)andcreateInstance(...)still work. The docs now lead withVault({ ... }).init(...)because it keeps authoring and instantiation on the same surface. - For class/decorator ports, move the class shape into
contract('Name', props, state?, body)and use the public authoring guides for the current functional model.
Artifact Output
buildArtifact returns an object with:
version(currently10)compilerVersion(currently1.0.0-fp)contractnameabientries (functions + constructor), preserving declared constructor prop and method param names/typesstructs,library, andaliasmetadata for user-defined schema and reusable library types- preserved
genericTypeson emitted struct and library metadata entries - generated state metadata via
stateProps, top-levelstateType, and generated<ContractName>State/<LibraryName>Statestruct entries when state is present buildTypeandfilehexlocking script (may include placeholders like<addr>)md5digest of the finalhex
Debug and Snapshot Workflow
Use debug builds plus the snapshot helpers when you want a supported local workflow for inspecting artifacts and runtime instances during contract iteration.
import {
and,
buildArtifact,
checkSig,
contract,
decodeArtifactHex,
eq,
formatArtifactDebug,
formatInstanceDebug,
hash160,
method,
snapshotArtifact,
snapshotInstance,
TypeTag,
} 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 bound = P2PKH({ addr: '00112233445566778899aabbccddeeff00112233' });
const artifact = buildArtifact(P2PKH, { buildType: 'debug' });
const instance = bound.init();
const artifactSnapshot = snapshotArtifact(artifact);
const instanceSnapshot = snapshotInstance(instance);
const tokens = decodeArtifactHex(artifact.hex);
const artifactDebug = formatArtifactDebug(artifact);
const instanceDebug = formatInstanceDebug(instance);
console.log(tokens);
console.log(JSON.stringify(artifactSnapshot, null, 2));
console.log(JSON.stringify(instanceSnapshot, null, 2));
console.log(artifactDebug);
console.log(instanceDebug);snapshotArtifact(...)omitscompilerVersionandmd5by default so snapshots stay focused on semantic diffs; pass{ includeCompilerMetadata: true }when you need them.snapshotInstance(...)normalizesbigintvalues into strings like"2n"and byte-oriented runtime values into hex strings, soJSON.stringify(..., null, 2)works directly in tests.decodeArtifactHex(...)exposes the opcode/placeholder stream used by the golden tests, which is useful when comparing local compiler output against fixtures.- Pass
{ includeDataPushTokens: true }todecodeArtifactHex(...)when you need pushed-byte boundaries asDATA(n)tokens for opcode-profile assertions. decodeArtifactHex(...)validates the full hex stream and throws on malformed or truncated input, so treat it as a strict local inspection tool rather than a lenient parser.formatArtifactDebug(...)andformatInstanceDebug(...)are meant for quick terminal inspection without adding custom logging code.
Project Layout
src/dsl: contract builders and primitive constructorssrc/ir: expression and contract IR definitionssrc/compiler: compile passes and artifact assemblysrc/types: opcode/type mappingstest: unit tests, golden fixtures, and docs/e2e checks
Current Prototype Limitations and Scope
TypeTag.Intis the default bigint-native integer type for new contracts.TypeTag.Int32remains available only as a deprecated compatibility alias.lit(value)is the built-in literal helper for integer, boolean, and byte-string expression nodes.- Struct and alias schemas are encoded and emitted as metadata, but contract expressions still treat them as opaque values; field projection and mutation helpers are outside the current surface.
- Struct
genericTypesare preserved in artifacts for consumers, but runtime encoding/decoding validates declared fields directly and does not specialize generic parameters. - Runtime/provider APIs are usable through the documented MemoryProvider and testnet workflows, but
0.1.xprovider details may still evolve before a stable release.
