Stateful Contracts
Use the four-argument contract(name, props, state, body) overload when the contract must validate and update persistent state across spends.
The public authoring flow is:
- Define a named contract.
- Bind props by calling the definition.
- Use
methods.<name>.next(...)for pure transitions. - Use
init(...)when you bridge into runtime instances.
Stateful contract()
import {
contract,
method,
TypeTag,
add,
and,
checkSig,
gte,
sub,
} 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)),
}),
),
deposit: method(
{ amount: TypeTag.Int },
(amount) => ({
// Pedagogical example: anyone can deposit. Add auth for production flows.
next: { balance: add(state.balance, amount) },
}),
),
}),
);
const vault = Vault({ owner: '02'.padEnd(66, '1') });
const s0 = { balance: 100n };
const s1 = vault.methods.withdraw.next(s0, { amount: 30n });
const s2 = vault.methods.deposit.next(s1, { amount: 50n });
const instance = vault.init(s0);
console.log(vault.artifact.contract); // Vault
console.log({ s0, s1, s2 });
console.log(instance.state); // { balance: 100n }
Stateful method return shape
| Field | Type | Description |
|---|---|---|
next | Record<string, ExprNode> | Next-state expressions for every declared state field. |
check | ExprNode | Predicate that must evaluate to true to spend the UTXO. |
Validation rules for next
next keys must exactly match the declared state schema.
The compiler throws on missing or extraneous keys.
import { contract, method, TypeTag, add, gt, lit } from '@opcat-labs/lambit';
const Ledger = contract(
'Ledger',
{},
{ balance: TypeTag.Int, nonce: TypeTag.Int },
({ state }) => ({
credit: method({ amount: TypeTag.Int }, (amount) => ({
next: {
balance: add(state.balance, amount),
nonce: add(state.nonce, lit(1n)),
},
check: gt(amount, lit(0n)),
})),
}),
);
State serialization and spend checks
buildArtifact() runs stateCompile() for stateful contracts.
That transform injects:
- current-state verification:
eq(spentDataHash, stateHash(currentState)) - next-output verification:
eq(hashOutputs, hash256(buildDataOutput(...)))
import { contract, method, TypeTag, add, gt, buildArtifact, 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 artifact = buildArtifact(Counter);
// Native stateful compilation (#129) emits executable bytecode with a
// preimage-commitment prologue and preimage-backed ctx slices, so no
// `<ctx_*>` UTF-8 placeholders survive in the deployed hex.
console.log(artifact.hex.includes('<spentDataHash>')); // false
console.log(artifact.hex.includes('<hashOutputs>')); // false
Terminal spends and branches
Stateful methods may return { outputs, check? } when the UTXO should be
consumed without creating a successor state. Native stateful spends choose the
sighash type per selected runtime path:
- successor-state outputs and single-output terminal paths use
SIGHASH_SINGLE - multi-output terminal paths use
SIGHASH_ALL
That means terminal stateful paths may now commit one or more outputs, but
funded providers still reject selected multi-output terminal spends because
wallet change would break the fixed hashOutputs commitment required by
SIGHASH_ALL.
Use branches(...) when one method should continue state on some runtime paths
and terminate with an output covenant on others.
Guarded arms are evaluated in the order written; the first matching when
branch wins, and any default arm must come last.
For a full authoring walkthrough of raw output builders, terminal payout
methods, and the E1/E2/E3 example ports, see
Output Covenants.
import {
add,
branches,
contract,
method,
p2pkhOutput,
TypeTag,
} from '@opcat-labs/lambit';
const Game = contract(
'Game',
{ winner: TypeTag.Ripemd160 },
{ pot: TypeTag.Int },
({ props, state }) => ({
move: method(
{ won: TypeTag.Bool, amount: TypeTag.Int },
(won, amount) => branches(
{ when: won, do: { outputs: [p2pkhOutput(state.pot, props.winner)] } },
{ default: { next: { pot: add(state.pot, amount) } } },
),
),
}),
);
const game = Game({ winner: '11'.repeat(20) });
const continued = game.methods.move.next({ pot: 9n }, { won: false, amount: 3n });
const moveAbi = game.artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'move',
);
console.log(continued); // { pot: 12n }
console.log(moveAbi?.returnShape); // branches
Correct and invalid state updates
State transitions must return every declared field exactly once. Keep unchanged fields explicit so the serialized next-state shape stays stable.
import { contract, method, TypeTag, add, lit } from '@opcat-labs/lambit';
const Ledger = contract(
'Ledger',
{},
{ balance: TypeTag.Int, nonce: TypeTag.Int },
({ state }) => ({
credit: method({ amount: TypeTag.Int }, (amount) => ({
// good, both state fields are present in the next-state object
next: {
balance: add(state.balance, amount),
nonce: add(state.nonce, lit(1n)),
},
// invalid, missing `nonce`
// next: { balance: add(state.balance, amount) },
})),
}),
);
const ledger = Ledger({});
console.log(ledger.artifact.stateProps.length); // 2
Migration notes
- Replace
stateful(...)authoring withcontract('Name', props, state, body). - Replace post-hoc naming such as
def.name = 'Vault'with the firstcontract(...)argument. - Prefer
const vault = Vault({ owner })andvault.init(state)when you already have the contract definition in hand.
Deploy/call lifecycle (stateful view)
- Define and bind with
const vault = Vault({ owner }). - Create the initial runtime instance with
vault.init(state). - Deploy a UTXO using that instance.
- Call a method by providing unlock args plus current state.
- Spend succeeds only if
checkis true andnextmatches outputs.
The state transition logic is deterministic because methods return pure expression trees.
Always update all declared state fields in next, even when a field is unchanged.
The runtime validates the previous state hash before accepting a successor
state, so unchanged fields still have to be repeated in next.1