Skip to main content

How to Debug a Contract

When a Lambit contract fails, reduce the problem to the smallest layer that can still reproduce it:

  1. authoring and method/schema shape
  2. compiled artifact and ABI
  3. pure state transitions
  4. offline runtime verification
  5. provider-backed deploy/call
  6. live testnet integration

That order matters. Most failures are easier to understand before signatures, funding, and network transport are involved.

1. Inspect the bound contract and artifact first

Before you deploy anything, confirm the compiler produced the contract surface you expect:

  • artifact.contract
  • artifact.abi
  • artifact.stateProps
  • artifact.hex
  • bound.methods.<name>.next(...) for stateful contracts
import { contract, method, TypeTag, add, gt, lit } from '@opcat-labs/lambit';

const Counter = contract(
'Counter',
{},
{ count: TypeTag.Int },
({ state }) => ({
increase: method({}, () => {
const nextCount = add(state.count, lit(1n));
return {
next: { count: nextCount },
check: gt(nextCount, state.count),
};
}),
}),
);

const bound = Counter({});
const artifact = bound.artifact;
const nextState = bound.methods.increase.next({ count: 2n });

console.log(artifact.contract); // Counter
console.log(artifact.abi.map((entry) => entry.name)); // ['increase']
console.log(artifact.stateProps); // [{ name: 'count', type: 'int' }]
console.log(nextState); // { count: 3n }

If the ABI, state fields, or compiled hex already look wrong here, the bug is still in the authoring/compiler layer. Do not move on to provider or testnet debugging yet.

2. Reproduce authoring failures locally

Compiler-facing failures usually point at schema drift, invalid state transitions, or method declarations that do not match the runtime ABI.

import { contract, method, TypeTag } from '@opcat-labs/lambit';

const failures: string[] = [];

try {
contract(
'BrokenArity',
{},
() => ({
unlock: method({ sig: TypeTag.Sig }, () => ({ tag: 'literal', value: 1n })),
}),
);
} catch (error) {
failures.push((error as Error).message);
}

try {
contract(
'BrokenState',
{},
{ balance: TypeTag.Int, nonce: TypeTag.Int },
() => ({
bump: method({ amount: TypeTag.Int }, (amount) => ({
next: { balance: amount },
})),
}),
);
} catch (error) {
failures.push((error as Error).message);
}

console.log(failures);

Typical messages include:

FailureWhat it usually meansFirst place to inspect
method(schema, fn): schema declares ...Method schema and function arity drifted apart.method(...) declaration
State key mismatch in method ...next does not exactly match the declared state schema.Stateful method return shape
Stateless contract method ... cannot reference state field ...A stateless method is reading state.Contract overload and method body
Stateful contract method ... terminal outputs must commit at least one outputA stateful terminal { outputs, check? } path returned an empty output list. Multi-output terminal spends are allowed via SIGHASH_ALL, but funded providers still reject those selected paths because they may append wallet change.Terminal branch or method return
Stateful contract method ... cannot produce both nextState and assertOutputs on the same execution pathOne stateful path mixes successor-state and terminal-output effects.Conditional/branches(...) return path

3. Preflight the spend before using a provider

Use MethodObject.verify(...) to prove the compiled script and unlocking arguments work against a synthetic runtime context. This is the fastest way to separate script failures from provider, funding, or broadcast failures.

import { expect } from 'chai';
import {
and,
checkSig,
contract,
createSigner,
eq,
hash160,
method,
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 signer = createSigner();
const address = await signer.getPublicKeyHash();
const publicKey = await signer.getPublicKey();
const p2pkh = P2PKH({ addr: address });

let verification: Promise<void> | undefined;
expect(() => {
verification = p2pkh.methods.unlock.verify(
{},
{ pubkey: publicKey },
{
context: {
satoshis: 1_000n,
txid: '22'.repeat(32),
vout: 5,
},
signer,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address }),
}),
},
);
}).not.to.throw();
await verification;

console.log('verify passed');

If this step fails, focus on the contract logic, args, and TxContext usage instead of network setup.

4. Reproduce the full flow with createMemoryProvider()

After verify(...) passes, use the in-memory runtime before moving to live infrastructure:

  • prepare a spend with deployed.methods.<name>.prepareCall(...) when you want to inspect it before broadcast
  • deploy with bound.deploy(state?, { provider, satoshis })
  • spend with deployed.methods.<name>.call(args?, { provider, signer?, invoke?, ... })
  • inspect spend.nextInstance, spend.txid, and the current provider UTXO set via await provider.listUtxos()
  • inspect snapshotPreparedCall(prepared) or formatPreparedCallDebug(prepared) when you need the exact selected method, invocation args, serialized outputs, hashes, and successor-state snapshot without adding ad hoc logging

This is the quickest path for debugging:

  • method argument mismatches
  • missing nextState
  • incorrect state serialization or output commitments
  • BVM verification failures during broadcast

The offline examples in Deployment, Examples, and the repository tests below are the best references for this stage.

5. Move to testnet only after offline reproduction is clean

When the offline path is green but testnet still fails, the issue is usually outside the contract logic:

Failure familyCommon causes
TestnetProvider.api: ...HTTP errors, invalid JSON, or API envelope drift
wallet ... has no spendable UTXOsFunding wallet is empty or UTXOs are already spent
input ... does not match stored UTXOLocal instance metadata no longer matches the live outpoint
context does not match spent UTXO and outputsThe prepared transaction no longer matches the instance or successor output

Use the guarded live testnet suite as the reference flow:

Common runtime failure modes

FailureWhat it meansRecommended next step
argument mismatch (missing [...]; unexpected [...])The named runtime args do not match the ABI.Compare the method call with artifact.abi and the method schema.
call: invokeMethod must call exactly one contract methodThe callback recorded zero calls or more than one call.Reduce the callback to a single contract.methods.<name>(...) invocation.
nextSatoshis requires nextStateA stateful successor amount was supplied without a successor state, so the runtime cannot commit to the next UTXO value and state together.Pass nextState or remove the stateful output override.
nextState transitions do not support additional outputsThe prepared stateful spend conflicts with extra outputs.Reproduce the transition without custom outputs first.
MethodObject.verify: signature-bearing method ... requires context.txid and context.voutSignature verification is missing prevout coordinates.Supply txid, vout, and satoshis in the verify context.
BVM verification failed ...The unlocking script and locking script disagree.Re-check args, expected signer/address, current state, and output commitments.

Tooling and references

ToolWhen to use it
npx tsc --noEmitCatch authoring and API-surface mistakes before runtime work.
npx eslint . --max-warnings 0Catch stale examples and unsafe debug scaffolding.
npm testRun unit, golden, runtime, and docs coverage together.
snapshotPreparedCall(prepared) / formatPreparedCallDebug(prepared)Inspect a prepared spend locally before broadcast, including resolved method selection, hashes, outputs, and successor-state metadata.
npx mocha --require tsx test/docs/howToWriteContractDocs.test.ts test/docs/howToWriteContractExamples.test.tsRe-check only the documentation surface after doc edits.
npm run test:testnetExercise the manual live-network path once offline debugging is clean.

Related docs:

note

Prefer MethodObject.verify(...) and createMemoryProvider() before debugging through a live wallet or remote provider. They remove funding noise and make failure messages deterministic.

Caution: If a stateful contract fails only on a later spend, inspect both the current state and the computed successor state. Many apparent signing bugs are actually output-commitment or state-serialization mismatches.

What's Next