How to Debug a Contract
When a Lambit contract fails, reduce the problem to the smallest layer that can still reproduce it:
- authoring and method/schema shape
- compiled artifact and ABI
- pure state transitions
- offline runtime verification
- provider-backed deploy/call
- live testnet integration
That order matters. Most failures are easier to understand before signatures, funding, and network transport are involved.
Recommended workflow
1. Inspect the bound contract and artifact first
Before you deploy anything, confirm the compiler produced the contract surface you expect:
artifact.contractartifact.abiartifact.statePropsartifact.hexbound.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:
| Failure | What it usually means | First 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 output | A 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 path | One 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 viaawait provider.listUtxos() - inspect
snapshotPreparedCall(prepared)orformatPreparedCallDebug(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 family | Common causes |
|---|---|
TestnetProvider.api: ... | HTTP errors, invalid JSON, or API envelope drift |
wallet ... has no spendable UTXOs | Funding wallet is empty or UTXOs are already spent |
input ... does not match stored UTXO | Local instance metadata no longer matches the live outpoint |
context does not match spent UTXO and outputs | The prepared transaction no longer matches the instance or successor output |
Use the guarded live testnet suite as the reference flow:
Common runtime failure modes
| Failure | What it means | Recommended 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 method | The callback recorded zero calls or more than one call. | Reduce the callback to a single contract.methods.<name>(...) invocation. |
nextSatoshis requires nextState | A 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 outputs | The prepared stateful spend conflicts with extra outputs. | Reproduce the transition without custom outputs first. |
MethodObject.verify: signature-bearing method ... requires context.txid and context.vout | Signature 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
| Tool | When to use it |
|---|---|
npx tsc --noEmit | Catch authoring and API-surface mistakes before runtime work. |
npx eslint . --max-warnings 0 | Catch stale examples and unsafe debug scaffolding. |
npm test | Run 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.ts | Re-check only the documentation surface after doc edits. |
npm run test:testnet | Exercise the manual live-network path once offline debugging is clean. |
Related docs:
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.