Skip to main content

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, and vault.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 with attachDeployedMethods(await provider.deploy(instance, satoshis)).

Lifecycle overview

StagePrototype APIOutput
Define + bindconst contract = Def(props)Bound contract with artifact, methods, and init().
Instantiatebound.init(state?)In-memory contract instance metadata.
Deploybound.deploy(state?, options)Contract UTXO reference with methods.
Calldeployed.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 P2PKH are already callable contract defs, so you can bind props and call init() directly.
  • If you already have artifact-first code, buildArtifact(def) and createInstance(...) still work.
  • For artifact-first deployment, call provider.deploy(instance, satoshis) and wrap the result with attachDeployedMethods(...) 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

  1. Name the contract in contract(name, ...) before binding props.
  2. Prefer bound.init(state?) when the contract definition is in scope.
  3. Keep provider selection explicit so bound.deploy(...) and deployed method .call(...) stay testable.
  4. Persist deployed outpoints (txid, vout, satoshis) if you need to resume later calls outside memory.

Compile, Instantiate, Fund, Deploy, And Call Locally

note

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:

  1. Compile by binding props: const vault = Vault({ owner }).
  2. Instantiate with vault.init(state) or createInstance(...).
  3. Seed a realistic wallet when you want local funding/change behavior.
  4. Deploy with vault.deploy(state, { provider, satoshis }).
  5. Call with deployed.methods.<name>.call(args?, { provider, signer?, invoke?, ... }).
  6. Pass nextState for 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

  1. Build the repo.
  2. Prefer a short-lived env var when you only need one test run.
  3. 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 Lambit bytecode 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 test does not run the manual testnet suite. npm run test:testnet skips cleanly unless LAMBIT_RUN_TESTNET_E2E=1 is set.
  • TESTNET_WIF must be a testnet WIF. createWifSigner(..., 'testnet') and getWifAddress(..., 'testnet') reject network mismatches.
  • Set TESTNET_API_BASE_URL only when targeting a non-default OpcatLayer API endpoint; omit it for the default testnet backend. createTestnetProvider() appends /api when 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 realistic mode, every contract output passed to the provider must include output.instance; omitting it fails before fee accounting can complete.
  • Invalid spends fail locally with BVM verification failed before 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 feeRate to createTestnetProvider({ ... }) to force a known sats/vB equivalent.
  • npm run print:testnet-address reads the built package from dist/index.js; run npm run build first if the helper reports that dist/ is missing.
  • Avoid putting raw WIFs in shell history or long-lived shell envs. Prefer read -s and short-lived env injection when possible.

Caution: There is no automated CI testnet suite yet. Treat npm run test:testnet as a manual verification gate until automated CI testnet coverage lands, and keep local offline coverage green before spending testnet coins.

What's Next