How to Deploy & Call
This is the canonical deploy/call/testnet verification guide for Lambit's native runtime.
The native deploy flow uses Lambit directly: contract(), bound .deploy() / .prepareDeploy(), deployed method .call() / .prepareCall(), createTestnetProvider(), and createWifSigner().
It does not delegate deployment or spends to scrypt-ts-opcat or another SDK runtime.
Pick The Entry Point
- If you are authoring the contract in this repo, use the bound-contract flow:
const vault = Vault(props),vault.artifact, andvault.init(state?). - Deploy bound contracts with
bound.deploy(state?, { provider, satoshis }). - Spend deployed contracts with
deployed.methods.<name>.call(args?, { provider, signer?, invoke?, ... }). - If you already have artifact JSON, use
createInstance({ artifact, constructorArgs, state? }), then attach the fluent spend surface withattachDeployedMethods(await provider.deploy(instance, satoshis)).
Lifecycle overview
| Stage | Prototype API | Output |
|---|---|---|
| Define + bind | const contract = Def(props) | Bound contract with artifact, methods, and init(). |
| Instantiate | bound.init(state?) | In-memory contract instance metadata. |
| Deploy | bound.deploy(state?, options) | Contract UTXO reference with methods. |
| Call | deployed.methods.<name>.call(args?, options) | Spending transaction reference. |
Compile, instantiate, deploy, call
import {
P2PKH,
createMemoryProvider,
createSigner,
} from '@opcat-labs/lambit';
const provider = createMemoryProvider();
const signer = createSigner();
const address = await signer.getPublicKeyHash();
const publicKey = await signer.getPublicKey();
const p2pkh = P2PKH({ addr: address });
const deployed = await p2pkh.deploy({
provider,
satoshis: 1_000n,
});
const spend = await deployed.methods.unlock.call(
{ pubkey: publicKey },
{
provider,
signer,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address }),
}),
},
);
console.log(p2pkh.artifact.contract);
console.log(deployed.utxo.txid);
console.log(spend.txid);
Migration note:
- This page uses the current fluent runtime surface. The older global deployment and spend helpers remain only as deprecated compatibility exports.
- Standard helpers such as
P2PKHare already callable contract defs, so you can bind props and callinit()directly. - If you already have artifact-first code,
buildArtifact(def)andcreateInstance(...)still work. - For artifact-first deployment, call
provider.deploy(instance, satoshis)and wrap the result withattachDeployedMethods(...)before spending. - The runtime validates the complete named arg set when the deployed method is prepared. Missing or unexpected args fail immediately during preparation.
Compile/call checklist
- Name the contract in
contract(name, ...)before binding props. - Prefer
bound.init(state?)when the contract definition is in scope. - Keep provider selection explicit so
bound.deploy(...)and deployed method.call(...)stay testable. - Persist deployed outpoints (
txid,vout, satoshis) if you need to resume later calls outside memory.
Compile, Instantiate, Fund, Deploy, And Call Locally
Start with this realistic memory-provider flow when you want a reproducible local walkthrough. It exercises wallet funding, fee accounting, contract deployment, and contract spends without requiring live network access.
createMemoryProvider(..., { realistic: ... }) is the closest offline stand-in for the live testnet provider.
It materializes wallet inputs and change outputs, so the same bound .deploy() and deployed method .call() flow works locally before you burn testnet funds.
In the realistic object, walletPubKeyHash sets the wallet change destination, walletUtxos provides illustrative funding inputs for the offline provider, and feeRate pins deterministic local fee accounting.
import {
and,
checkSig,
contract,
createMemoryProvider,
createSigner,
gte,
method,
sub,
TypeTag,
} 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)),
}),
),
}),
);
// Generates an ephemeral random keypair with no on-chain funds.
// Use it for offline walkthroughs only; swap in createWifSigner(...) on testnet.
const signer = createSigner();
const owner = await signer.getPublicKey();
const walletPubKeyHash = await signer.getPublicKeyHash();
const vault = Vault({ owner });
const artifact = vault.artifact;
const instance = vault.init({ balance: 100n });
const provider = createMemoryProvider([], {
realistic: {
walletPubKeyHash,
// Illustrative offline wallet input; live flows use chain-discovered UTXOs.
walletUtxos: [{
txid: 'aa'.repeat(32),
vout: 0,
satoshis: 25_000n,
}],
feeRate: 0.5,
},
});
const deployed = await vault.deploy({ balance: 100n }, {
provider,
satoshis: 1_000n,
});
const nextState = vault.methods.withdraw.next(instance.state, { amount: 30n });
const spend = await deployed.methods.withdraw.call(
{ amount: 30n },
{
provider,
signer,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: owner }),
}),
nextState,
},
);
const liveSpent = await provider.getUtxo(deployed.utxo.txid, deployed.utxo.vout);
console.log(artifact.contract); // Vault
console.log(spend.nextInstance?.state); // { balance: 70n }
console.log(liveSpent); // undefined
Checklist:
- Compile by binding props:
const vault = Vault({ owner }). - Instantiate with
vault.init(state)orcreateInstance(...). - Seed a realistic wallet when you want local funding/change behavior.
- Deploy with
vault.deploy(state, { provider, satoshis }). - Call with
deployed.methods.<name>.call(args?, { provider, signer?, invoke?, ... }). - Pass
nextStatefor stateful spends so the successor output matches the method transition.
What Changes On Testnet
Swap the local signer/provider for the WIF-backed testnet pair:
const signer = createWifSigner(TESTNET_WIF, 'testnet')const provider = createTestnetProvider({ wif: TESTNET_WIF, network: 'testnet' })
Do not carry the memory provider's realistic.walletPubKeyHash or realistic.walletUtxos into testnet scripts.
createTestnetProvider() discovers wallet UTXOs from the chain backend and handles wallet change internally.
Everything else stays on the same Lambit runtime:
- build/bind the contract
- deploy through the bound contract
- spend through
deployed.methods.<name>.call(...)
If you already have a compiled artifact instead of a bound contract, the instance shape is still createInstance({ artifact, constructorArgs, state }); only the authoring entry point changes.
Testnet Verification Flow
- Build the repo.
- Prefer a short-lived env var when you only need one test run.
- Export a funded testnet WIF only if you need the variable across multiple commands.
Configure Your Script
Use the WIF-backed signer and provider when you move your own local deploy/call script to testnet.
Load TESTNET_WIF from your local secret flow before creating the signer and provider.
import { createTestnetProvider, createWifSigner, getWifAddress } from '@opcat-labs/lambit';
const TESTNET_WIF = process.env.TESTNET_WIF;
if (!TESTNET_WIF) {
throw new Error('Set TESTNET_WIF before running this script.');
}
const signer = createWifSigner(TESTNET_WIF, 'testnet');
const provider = createTestnetProvider({
wif: TESTNET_WIF,
network: 'testnet',
// Optional override for a non-default OpcatLayer API endpoint.
// apiBaseUrl: process.env.TESTNET_API_BASE_URL,
// Optional fallback when the fee-estimate endpoint is unstable.
// feeRate: 1,
});
const fundingAddress = getWifAddress(TESTNET_WIF, 'testnet');
const publicKey = await signer.getPublicKey();
console.log(fundingAddress);
console.log(publicKey);
Safer default for a single run:
The final unset clears the shell variable created by read; the inline TESTNET_WIF=... and LAMBIT_RUN_TESTNET_E2E=1 values do not persist in the parent shell.
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" npm run test:testnet
unset TESTNET_WIF
If you need the variable across multiple commands:
npm run build
read -s TESTNET_WIF
export LAMBIT_RUN_TESTNET_E2E=1
export TESTNET_WIF
npm run print:testnet-address
npm run test:testnet
unset LAMBIT_RUN_TESTNET_E2E TESTNET_WIF
The suite in test/e2e/testnetLive.test.ts is the current source of truth for live-network verification.
It exercises the native runtime directly and validates that:
- P2PKH deploys and spends on OP_CAT testnet.
- Counter deploys, increments, and rejects an invalid state transition before broadcast.
- HTLC claim, refund, and cooperative paths execute through the native runtime.
- RollupStats multi-field state transitions cover the same deploy/call flow for a larger state shape.
- Multi-method/stateful manual flows keep using
Lambitbytecode and runtime helpers rather than SDK delegation.
The helper script calls getWifAddress(process.env.TESTNET_WIF, 'testnet') from the built package when printing the funding address:
npm run print:testnet-address
Known Gotchas
npm testdoes not run the manual testnet suite.npm run test:testnetskips cleanly unlessLAMBIT_RUN_TESTNET_E2E=1is set.TESTNET_WIFmust be a testnet WIF.createWifSigner(..., 'testnet')andgetWifAddress(..., 'testnet')reject network mismatches.- Set
TESTNET_API_BASE_URLonly when targeting a non-default OpcatLayer API endpoint; omit it for the default testnet backend.createTestnetProvider()appends/apiwhen normalizing the base URL. Startup and skip diagnostics redact URL credentials, query parameters, and fragments so endpoint tokens are not logged. - The same WIF funds deploy/call fees and signs the manual P2PKH path.
createTestnetProvider()fetches fee estimates, aggregates wallet UTXOs when needed, and adds wallet change automatically. Stateful successor outputs stay ahead of wallet change outputs.- In
realisticmode, every contract output passed to the provider must includeoutput.instance; omitting it fails before fee accounting can complete. - Invalid spends fail locally with
BVM verification failedbefore broadcast, which is how the manual Counter rejection check verifies the live-network path without burning funds. - The manual Counter flow intentionally leaves the latest contract UTXO live on testnet. Repeated runs consume wallet balance unless you spend that UTXO later or rotate wallets.
- If the fee-estimate endpoint is unstable, pass
feeRatetocreateTestnetProvider({ ... })to force a known sats/vB equivalent. npm run print:testnet-addressreads the built package fromdist/index.js; runnpm run buildfirst if the helper reports thatdist/is missing.- Avoid putting raw WIFs in shell history or long-lived shell envs. Prefer
read -sand short-lived env injection when possible.
Caution: There is no automated CI testnet suite yet. Treat
npm run test:testnetas a manual verification gate until automated CI testnet coverage lands, and keep local offline coverage green before spending testnet coins.