Skip to main content

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:

  1. Define a named contract.
  2. Bind props by calling the definition.
  3. Use methods.<name>.next(...) for pure transitions.
  4. 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

FieldTypeDescription
nextRecord<string, ExprNode>Next-state expressions for every declared state field.
checkExprNodePredicate 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 with contract('Name', props, state, body).
  • Replace post-hoc naming such as def.name = 'Vault' with the first contract(...) argument.
  • Prefer const vault = Vault({ owner }) and vault.init(state) when you already have the contract definition in hand.

Deploy/call lifecycle (stateful view)

  1. Define and bind with const vault = Vault({ owner }).
  2. Create the initial runtime instance with vault.init(state).
  3. Deploy a UTXO using that instance.
  4. Call a method by providing unlock args plus current state.
  5. Spend succeeds only if check is true and next matches outputs.
note

The state transition logic is deterministic because methods return pure expression trees.

caution

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

What's Next

Footnotes

  1. Repeating every field keeps the serialized state shape stable across spends and lets stateHash(...) compare the old and new commitments deterministically.