Interact with a Deployed Contract
Overview
In this tutorial we interact with a deployed smart contract by calling one of its public methods, from a separate process or from a different party.
To do that we need an instance of the contract that corresponds to the deployed on-chain UTXO. In Lambit that instance is produced by the same contract definition that authored the locking script — either a bound contract (const c = Def(props)) or an artifact-first instance (createInstance({ artifact, constructorArgs, state })).
The Smart Contract
We reuse the stateful Counter contract from a previous step, authored with the functional DSL:
import { add, contract, gt, lit, method, TypeTag } from "@opcat-labs/lambit";
export const Counter = contract(
"Counter",
{},
{ count: TypeTag.Int },
({ state }) => ({
increase: method({}, () => {
const nextCount = add(state.count, lit(1n));
return {
// Successor state committed to the next output.
next: { count: nextCount },
// Guard predicate: the count must actually increase.
check: gt(nextCount, state.count),
};
}),
}),
);
A stateful method returns { next, check? }: next describes the successor state for every declared state field, and check is the predicate that must hold for the spend to succeed. The compiler turns next into the successor-output commitment automatically, so the runtime can validate the transition when we call the method.
Bind, Instantiate, and Deploy
Because Counter has no props, binding is a plain call to the contract definition. Then we deploy with an initial state through the bound contract's deploy(state?, { provider, satoshis }):
import { createMemoryProvider } from "@opcat-labs/lambit";
// `Counter` is the user-defined contract from the section above.
import { Counter } from "./counter";
const provider = createMemoryProvider();
const counter = Counter({}); // bind props (none here)
const initialState = { count: 0n };
const deployed = await counter.deploy(initialState, {
provider,
satoshis: 1_000n,
});
console.log(
`Counter deployed: ${deployed.utxo.txid}, the count is: ${initialState.count}`,
);
Counter({})binds the contract definition and exposes.artifact,.methods,.init(), and.deploy().deployedis the on-chain instance: it carries.utxo(txid,vout,satoshis) and the fluent.methodsspend surface.- On testnet, replace the memory provider with the WIF-backed pair —
createWifSigner(TESTNET_WIF, 'testnet')andcreateTestnetProvider({ wif: TESTNET_WIF, network: 'testnet' })— the rest of the flow is unchanged.
Compute the Successor State
Before spending a stateful contract we compute the successor state the transaction will commit to. The bound contract exposes a pure transition function per method:
const nextState = counter.methods.increase.next(initialState);
// { count: 1n }
methods.<name>.next(state, args?) evaluates the method's next expression without building or broadcasting a transaction. It is the deterministic, side-effect-free way to know the successor state ahead of time.
Interact with the Call Feature
Now we spend the deployed UTXO by calling the public method. The deployed instance's methods.<name>.call(args?, options) builds the spending transaction, verifies it in the BVM, and broadcasts it:
import { createMemoryProvider } from "@opcat-labs/lambit";
// `Counter` is the user-defined contract from the section above.
import { Counter } from "./counter";
const provider = createMemoryProvider();
const counter = Counter({});
const state = { count: 0n };
const deployed = await counter.deploy(state, { provider, satoshis: 1_000n });
const nextState = counter.methods.increase.next(state);
const callTx = await deployed.methods.increase.call(
{},
{
provider,
nextState,
},
);
console.log(`Counter contract called: ${callTx.txid}`);
Key points:
- The deployed instance already knows the current UTXO — we do not pass the current state again. The runtime reads
statefromdeployed.utxoand validates it against the spent data hash. nextStateis the successor state the transaction commits to. Omitting it for a stateful transition fails during call preparation.callTx.nextInstanceis the successor deployed instance, pointing at the new contract UTXO. Chain further calls on it.callTx.txidis the resulting transaction id; the old UTXO is consumed.
CallOptions
methods.<name>.call(args?, options) accepts the following options (a subset of DeployedMethodCallOptions / PrepareCallArgs):
/**
* Options for calling a smart contract method.
* @property provider - Provider that funds, broadcasts, and verifies the spend.
* @property signer - Signer used to resolve `psbt.getSig(...)` requests from `invoke`.
* @property invoke - Callback that records the method invocation and returns signature requests.
* @property nextState - Successor state for a stateful transition.
* @property nextSatoshis - Successor UTXO value (requires `nextState`).
* @property outputs - Explicit terminal outputs for terminal methods.
* @property txContext - Overrides for version / inputSequence / locktime.
*/
export interface DeployedMethodCallOptions {
provider: Provider;
signer?: Signer;
invoke?: (
psbt: RuntimePsbtBuilder,
) => RuntimeMethodArgs | undefined | Promise<RuntimeMethodArgs | undefined>;
nextState?: RuntimeNamedValues;
nextSatoshis?: bigint;
outputs?: RuntimeOutput[];
txContext?: RuntimeTransactionContextOptions;
}
For signature-bearing methods, pass
signerand aninvokecallback that returnspsbt.getSig(0, { publicKey | address })requests; the runtime resolves them through the signer:const spend = await deployed.methods.spend.call(
{ amount: 30n },
{
provider,
signer,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: owner }),
}),
nextState: { balance: 70n },
},
);For terminal methods that pay out, pass
outputs(or rely on a{ outputs, check }method body) and let the runtime derive the output vector.Use
txContext: { locktime, inputSequence, version }for timelock-guarded methods.
Putting It Together
The following program deploys the counter and increments it five times, advancing the instance and state after every spend:
import { createMemoryProvider } from "@opcat-labs/lambit";
// `Counter` is the user-defined contract from the section above.
import { Counter } from "./counter";
async function main() {
const provider = createMemoryProvider();
const counter = Counter({});
let state = { count: 0n };
const deployed = await counter.deploy(state, { provider, satoshis: 1_000n });
console.log(`Counter deployed: ${deployed.utxo.txid}`);
let instance = deployed;
for (let i = 0; i < 5; i++) {
const nextState = counter.methods.increase.next(state);
const callTx = await instance.methods.increase.call(
{},
{ provider, nextState },
);
console.log(
`Counter called: ${callTx.txid}, count is now: ${nextState.count}`,
);
// Advance to the successor instance and state for the next iteration.
instance = callTx.nextInstance!;
state = nextState;
}
}
void main();
Executing the program produces output similar to:
Counter deployed: 1b1c09383f6a483dabf138111cf80e6921cc2050ffdbb6c7493f47a2c3759180
Counter called: ec07e45c9114ce2b4f439414f0c79e13e90e3157f6dae6c1e66510d7f2cecc6c, count is now: 1
Counter called: f65967b0cfe84e7e8c7b54bda2ce6f216177f87bbc95561044470321f435c07c, count is now: 2
Counter called: e4f0862c62a6c42e10097c0e1b975710f3a4ef0f768a694e5edc7c4bd20997eb, count is now: 3
Counter called: 68da33af2c64657de41dfcd9c525f3f8c37cffd28be8a4a5374bc8ea31e8f7b5, count is now: 4
Counter called: 24033524f0bceb18172f9bbb4c3baecdcf8f04e233bd13ed27c9061e0f224d4d, count is now: 5
Interact with a Custom Transaction
For more complex flows — offline signers, multi-input transactions, or inspection before broadcast — use methods.<name>.prepareCall(...) instead of .call(...). It performs the same provider-aware preflight checks and builds the full prepared spend without broadcasting. The returned PreparedCall extends RuntimePsbt and exposes the unlocking script, the message digest to sign, the serialized outputs, and the successor instance:
import { createMemoryProvider } from "@opcat-labs/lambit";
// `Counter` is the user-defined contract from the section above.
import { Counter } from "./counter";
const provider = createMemoryProvider();
const counter = Counter({});
const deployed = await counter.deploy(
{ count: 0n },
{ provider, satoshis: 1_000n },
);
const nextState = counter.methods.increase.next({ count: 0n });
// Build the spend without broadcasting.
const prepared = await deployed.methods.increase.prepareCall(
{},
{ provider, nextState },
);
// Inspect the prepared spend before it leaves this process.
console.log(prepared.unlockingScriptHex); // finalized unlocking script
console.log(prepared.messageHex); // digest an offline signer would sign
console.log(prepared.outputs); // serialized output vector
console.log(prepared.nextInstance?.state); // { count: 1n }
// Broadcast the prepared payload directly.
const result = await provider.broadcast(prepared);
console.log(result.txid);
prepareCall always attaches a runtime unlocking-script resolver, so providers that fund or otherwise reshape the transaction re-resolve the unlocking script against their final transaction before broadcast.
Lower-level transaction primitives
When you need even finer control than prepareCall, Lambit exports the primitives the fluent runtime is built on:
materializeLockingScript(instance, context?)— the deployed locking script with context placeholders filled.buildPsbtMessage(input, lockingScriptHex, outputs, txContext)— the double-SHA-256 digest the contract signs (txid || vout || satoshis || version || inputSequence || locktime || lockingScriptLength || lockingScript || outputCount || outputs...).buildBvmTransaction(input, lockingScriptHex, stateSerializationHex, outputs, txContext)— a BVM-runnable transaction for offline verification.createRuntimePsbtBuilder(...)/getRuntimeUnlockingScriptResolver(...)— the PSBT builder and resolver used by the fluent runtime.
These are the same helpers the runtime uses internally; prefer the fluent .deploy() / .prepareCall() / .call() surface unless you are building a custom tool.
Artifact-First Deployment
If you already have compiled artifact JSON (for example, produced by lambit compile or buildArtifact), create the instance with createInstance({ artifact, constructorArgs, state }), deploy through the provider, and attach the fluent spend surface with attachDeployedMethods(...):
import {
attachDeployedMethods,
buildArtifact,
createInstance,
createMemoryProvider,
} from "@opcat-labs/lambit";
// `Counter` is the user-defined contract from the section above.
import { Counter } from "./counter";
const artifact = buildArtifact(Counter);
const instance = createInstance({
artifact,
constructorArgs: {},
state: { count: 0n },
});
const provider = createMemoryProvider();
const deployed = attachDeployedMethods(await provider.deploy(instance, 1_000n));
const nextState = Counter({}).methods.increase.next({ count: 0n });
const callTx = await deployed.methods.increase.call(
{},
{ provider, nextState },
);
console.log(`Counter contract called: ${callTx.txid}`);
Conclusion
You have now deployed a Lambit smart contract, computed a pure successor state, called a public method through the fluent runtime, inspected a prepared spend, and broadcast it — both on the in-memory provider and (by swapping in createWifSigner / createTestnetProvider) on testnet.