Output Covenants
Output covenants let a contract commit to the outputs created by the spend, not
just the predicate that unlocks the current UTXO.
In Lambit, that surface splits into two layers:
- raw serialized-output helpers for
hashOutputscommitments - stateful method return shapes that can terminate the state chain with explicit payouts
Prefer p2pkhOutput(...) and scriptOutput(...) when you want readable runtime outputs. Reach for buildP2pkhOutput(...), buildContractOutput(...), and assertOutputs(...) when you need exact serialized-output bytes or a full output-vector commitment.
If you have not read the earlier guides yet, start with Basics and Stateful Contracts, then come back here for payout-specific authoring patterns.
The three primitives
Lambit exposes three low-level helpers for output-covenant work:
buildP2pkhOutput(pkh, satoshis)builds one serialized P2PKH output.buildContractOutput(script, satoshis)builds one serialized output for an arbitrary scriptPubKey.assertOutputs(outputs)commitshashOutputsto the exact serialized output vector.
For native stateful data outputs, buildDataOutput(scriptHash, value, dataHash)
is also exported. That helper is covered in
Transaction Context because it composes the
native state-hash output layout instead of a general-purpose spendable
scriptPubKey.
The raw builders use wire-format argument order:
buildP2pkhOutput(pkh, satoshis)buildContractOutput(script, satoshis)
That differs from the higher-level runtime helpers:
p2pkhOutput(satoshis, pkh)scriptOutput(satoshis, script, dataHash?)
Treat those as two separate API layers rather than one overloaded family: the raw helpers build serialized output bytes, while the runtime helpers describe terminal outputs in authoring-friendly form.
Use the raw helpers when you are authoring a stateless hashOutputs
commitment directly.
When stateful methods read ctx.value or ctx.hashOutputs through the trailing
TxContext proxy, or return terminal { outputs, check } shapes, those fields
come from transaction context and are auto-filled by the runtime.
import {
assertOutputs,
buildArtifact,
buildContractOutput,
buildP2pkhOutput,
contract,
lit,
method,
toByteString,
TypeTag,
} from '@opcat-labs/lambit';
const OutputShapes = contract(
'OutputShapes',
{ recipient: TypeTag.Ripemd160 },
({ props }) => ({
lock: method({ value: TypeTag.Int }, (value) => assertOutputs([
buildP2pkhOutput(props.recipient, value),
buildContractOutput(toByteString('6a'), lit(0n)),
])),
}),
);
const artifact = buildArtifact(OutputShapes);
const lockAbi = artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'lock',
);
console.log(lockAbi?.name); // lock
console.log(artifact.hex.length > 0); // true
Terminal methods
Stateful methods do not have to return a successor state.
When the UTXO should end and pay someone instead, return { outputs, check? }
instead of { next, check? }.
Use { next, check } when the contract state should keep living at a successor
output.
Use { outputs, check } when the selected path should terminate the state chain
and enforce one or more payout outputs.
When a terminal path needs the real spent amount, prefer ctx.value over a
state field unless your contract explicitly proves those two always stay in
sync.
import {
add,
buildArtifact,
contract,
gt,
lit,
method,
p2pkhOutput,
TypeTag,
} from '@opcat-labs/lambit';
const Vault = contract(
'Vault',
{ owner: TypeTag.Ripemd160 },
{ balance: TypeTag.Int },
({ props, state }) => ({
deposit: method({ amount: TypeTag.Int }, (amount) => ({
next: { balance: add(state.balance, amount) },
})),
close: method({}, (ctx) => ({
outputs: [p2pkhOutput(ctx.value, props.owner)],
check: gt(ctx.value, lit(0n)),
})),
}),
);
const artifact = buildArtifact(Vault);
const depositAbi = artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'deposit',
);
const closeAbi = artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'close',
);
console.log(depositAbi?.returnShape); // stateful
console.log(closeAbi?.returnShape); // terminal
branches(...) combinator
Use branches(...) when one method needs mixed dispatch:
- some runtime paths return
{ next, check } - others return
{ outputs, check }
Every arm must be exhaustive at the method level:
- guarded arms run in the order written
- the first matching
whenarm wins defaultmust come last if present- each arm must choose exactly one effect shape:
nextoroutputs
import {
add,
branches,
contract,
method,
p2pkhOutput,
TypeTag,
} from '@opcat-labs/lambit';
const Pot = contract(
'Pot',
{ winner: TypeTag.Ripemd160 },
{ amount: TypeTag.Int },
({ props, state }) => ({
move: method(
{ won: TypeTag.Bool, increment: TypeTag.Int },
(won, increment, ctx) => branches(
{
when: won,
do: {
outputs: [p2pkhOutput(ctx.value, props.winner)],
},
},
{ default: { next: { amount: add(state.amount, increment) } } },
),
),
}),
);
const pot = Pot({ winner: '11'.repeat(20) });
const continued = pot.methods.move.next({ amount: 9n }, { won: false, increment: 3n });
const moveAbi = pot.artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'move',
);
console.log(continued); // { amount: 12n }
console.log(moveAbi?.returnShape); // branches
Walkthrough 1: TicTacToe winner payout
The TicTacToe port keeps the board in one fixedArray(TypeTag.Int, 9) state
field, then switches to a terminal payout when the just-played move either wins
or fills the board.
The payout logic shipped in the fixture is exactly:
return branches(
{
when: won,
do: {
outputs: [p2pkhOutput(ctx.value, hash160(currentPlayer))],
check: precondition,
},
},
{
when: full,
do: {
outputs: [
// Odd satoshi draws round the remainder toward Alice so the
// split stays deterministic across offline/live tests.
p2pkhOutput(sub(ctx.value, div(ctx.value, literal(2n))), hash160(props.alice)),
p2pkhOutput(div(ctx.value, literal(2n)), hash160(props.bob)),
],
check: precondition,
},
},
{
default: {
next: {
board: nextBoard,
isAliceTurn: not(state.isAliceTurn),
},
check: precondition,
},
},
);
The end-to-end effect is that a winning move spends the contract UTXO once and replaces it with a single P2PKH output to the winner.
import {
and,
branches,
checkSig,
cond,
contract,
createMemoryProvider,
createSigner,
div,
eq,
ExprNode,
FixedArray,
gte,
hash160,
lit as literal,
lt,
method,
neq,
not,
p2pkhOutput,
some,
sub,
TypeTag,
} from '@opcat-labs/lambit';
const TICTACTOE_WIN_LINES: ReadonlyArray<readonly [number, number, number]> = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
function ticTacToeBoolEq(left: ExprNode, right: ExprNode): ExprNode {
return cond(eq(left, right), literal(true), literal(false));
}
const ticTacToeBoard = FixedArray(TypeTag.Int, 9);
function ticTacToeMarkAfterMove(
board: { at(index: ExprNode): ExprNode },
moveIndex: ExprNode,
targetIndex: number,
mark: ExprNode,
): ExprNode {
return cond(
eq(moveIndex, literal(BigInt(targetIndex))),
literal(true),
ticTacToeBoolEq(board.at(literal(BigInt(targetIndex))), mark),
);
}
function ticTacToeWon(
board: { at(index: ExprNode): ExprNode },
moveIndex: ExprNode,
mark: ExprNode,
): ExprNode {
return some(TICTACTOE_WIN_LINES, ([a, b, c]) => and(
ticTacToeMarkAfterMove(board, moveIndex, a, mark),
and(
ticTacToeMarkAfterMove(board, moveIndex, b, mark),
ticTacToeMarkAfterMove(board, moveIndex, c, mark),
),
));
}
const TicTacToe = contract(
'TicTacToe',
{ alice: TypeTag.PubKey, bob: TypeTag.PubKey },
{ board: ticTacToeBoard, isAliceTurn: TypeTag.Bool },
({ props, state }) => ({
move: method(
{
playerSig: TypeTag.Sig,
n: TypeTag.Int,
},
(playerSig, n, ctx) => {
const currentPlayer = cond(state.isAliceTurn, props.alice, props.bob);
const currentMark = cond(state.isAliceTurn, literal(1n), literal(2n));
const isBoundedMove = and(gte(n, literal(0n)), lt(n, literal(9n)));
const safeIndex = cond(isBoundedMove, n, literal(0n));
const nextBoard = state.board.set(safeIndex, currentMark);
const precondition = and(
isBoundedMove,
and(
eq(state.board.at(safeIndex), literal(0n)),
checkSig(playerSig, currentPlayer),
),
);
const won = ticTacToeWon(state.board, safeIndex, currentMark);
const full = state.board.every((cell, index) => cond(
eq(index, safeIndex),
literal(true),
neq(cell, literal(0n)),
));
return branches(
{
when: won,
do: {
outputs: [p2pkhOutput(ctx.value, hash160(currentPlayer))],
check: precondition,
},
},
{
when: full,
do: {
outputs: [
// Odd satoshi draws round the remainder toward Alice so the
// split stays deterministic across offline/live tests.
p2pkhOutput(sub(ctx.value, div(ctx.value, literal(2n))), hash160(props.alice)),
p2pkhOutput(div(ctx.value, literal(2n)), hash160(props.bob)),
],
check: precondition,
},
},
{
default: {
next: {
board: nextBoard,
isAliceTurn: not(state.isAliceTurn),
},
check: precondition,
},
},
);
},
),
}),
);
function emptyBoard() {
return {
board: [0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n],
isAliceTurn: true,
};
}
const provider = createMemoryProvider();
const alice = createSigner();
const bob = createSigner();
const alicePubKey = await alice.getPublicKey();
const bobPubKey = await bob.getPublicKey();
const alicePkh = await alice.getPublicKeyHash();
const bobPkh = await bob.getPublicKeyHash();
const game = TicTacToe({ alice: alicePubKey, bob: bobPubKey });
const deployed = await game.deploy(emptyBoard(), {
provider,
satoshis: 1_001n,
});
const first = await deployed.methods.move.call(
{ n: 0n },
{
provider,
signer: alice,
invoke: (psbt) => ({
playerSig: psbt.getSig(0, { publicKey: alicePubKey }),
}),
},
);
const second = await first.nextInstance!.methods.move.call(
{ n: 3n },
{
provider,
signer: bob,
invoke: (psbt) => ({
playerSig: psbt.getSig(0, { publicKey: bobPubKey }),
}),
},
);
const third = await second.nextInstance!.methods.move.call(
{ n: 1n },
{
provider,
signer: alice,
invoke: (psbt) => ({
playerSig: psbt.getSig(0, { publicKey: alicePubKey }),
}),
},
);
const fourth = await third.nextInstance!.methods.move.call(
{ n: 4n },
{
provider,
signer: bob,
invoke: (psbt) => ({
playerSig: psbt.getSig(0, { publicKey: bobPubKey }),
}),
},
);
const artifactStateProps = game.artifact.stateProps;
const continuedBoard = first.nextInstance!.state.board;
const continuedTurn = first.nextInstance!.state.isAliceTurn;
const win = await fourth.nextInstance!.methods.move.call(
{ n: 2n },
{
provider,
signer: alice,
invoke: (psbt) => ({
playerSig: psbt.getSig(0, { publicKey: alicePubKey }),
}),
},
);
const drawDeployed = await game.deploy(emptyBoard(), {
provider,
satoshis: 1_001n,
});
const drawFirst = await drawDeployed.methods.move.call({ n: 0n }, {
provider,
signer: alice,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: alicePubKey }) }),
});
const drawSecond = await drawFirst.nextInstance!.methods.move.call({ n: 1n }, {
provider,
signer: bob,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: bobPubKey }) }),
});
const drawThird = await drawSecond.nextInstance!.methods.move.call({ n: 2n }, {
provider,
signer: alice,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: alicePubKey }) }),
});
const drawFourth = await drawThird.nextInstance!.methods.move.call({ n: 4n }, {
provider,
signer: bob,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: bobPubKey }) }),
});
const drawFifth = await drawFourth.nextInstance!.methods.move.call({ n: 3n }, {
provider,
signer: alice,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: alicePubKey }) }),
});
const drawSixth = await drawFifth.nextInstance!.methods.move.call({ n: 5n }, {
provider,
signer: bob,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: bobPubKey }) }),
});
const drawSeventh = await drawSixth.nextInstance!.methods.move.call({ n: 7n }, {
provider,
signer: alice,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: alicePubKey }) }),
});
const drawEighth = await drawSeventh.nextInstance!.methods.move.call({ n: 6n }, {
provider,
signer: bob,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: bobPubKey }) }),
});
const draw = await drawEighth.nextInstance!.methods.move.call({ n: 8n }, {
provider,
signer: alice,
invoke: (psbt) => ({ playerSig: psbt.getSig(0, { publicKey: alicePubKey }) }),
});
const winnerPkh = alicePkh;
const winnerOutputs = win.outputs;
const drawOutputs = draw.outputs;
console.log(artifactStateProps[0]); // { name: 'board', type: 'int[9]' }
console.log(continuedBoard); // [1n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n]
console.log(continuedTurn); // false
console.log(win.nextInstance === undefined); // true
console.log(winnerOutputs.length); // 1
console.log(draw.outputs.length); // 2
Walkthrough 2: Auction seller payout
The Auction port keeps bid() stateful and makes close() terminal.
That separation is the main authoring pattern: the live auction keeps rolling
forward through successor states until the seller chooses the terminal payout
path.
A terminal payout variant keeps bid() stateful and gives close() a trailing
context parameter:
bid: auctionBidMethod(state),
close: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey, ctx) => {
const sellerMatches = eq(hash160(pubkey), props.seller);
const sequenceIsNonFinal = neq(ctx.nSequence, finalizedSequenceSentinelHexLiteral());
const locktimeIsBlockHeight = isBlockHeightLocktime(ctx.nLockTime);
const locktimeMeetsCloseHeight = gte(ctx.nLockTime, props.closeHeight);
return {
outputs: [p2pkhOutput(ctx.value, props.seller)],
check: and(
sellerMatches,
and(
sequenceIsNonFinal,
and(
locktimeIsBlockHeight,
and(
locktimeMeetsCloseHeight,
checkSig(sig, pubkey),
),
),
),
),
};
},
),
This is the full local flow on MemoryProvider: one bid() transition creates
the successor Auction UTXO, then close() spends that successor and pays the
seller at vout 0.
The runnable block below shows the explicit trailing-ctx form. The shipped
fixture above keeps close() at { sig, pubkey } and reads the spent amount
through a preimage field helper, but the payout logic is the same in both
versions.
Notice the runtime uses the same deployed method .call(...) shape for both
paths: bid() only needs { provider, nextState }, while close() also passes
signer and invoke because the seller must sign and the terminal path
produces no successor state.
import {
and,
checkSig,
contract,
createMemoryProvider,
createSigner,
eq,
ExprNode,
gt,
gte,
hash160,
lit as literal,
lt,
method,
neq,
p2pkhOutput,
TypeTag,
} from '@opcat-labs/lambit';
function finalizedSequenceSentinelHexLiteral(): ExprNode {
return literal('ffffffff');
}
function timestampLocktimeThresholdLiteral(): ExprNode {
return literal(500_000_000n);
}
function isBlockHeightLocktime(value: ExprNode): ExprNode {
return lt(value, timestampLocktimeThresholdLiteral());
}
function auctionBidMethod(state: { highestBid: ExprNode }) {
const { highestBid } = state;
return method(
{ newBid: TypeTag.Int, newBidder: TypeTag.Ripemd160 },
(newBid, newBidder) => ({
next: {
highestBid: newBid,
highestBidder: newBidder,
},
check: gt(newBid, highestBid),
}),
);
}
const Auction = contract(
'Auction',
{ seller: TypeTag.Ripemd160, closeHeight: TypeTag.Int },
{ highestBid: TypeTag.Int, highestBidder: TypeTag.Ripemd160 },
({ props, state }) => ({
bid: auctionBidMethod(state),
close: method(
{
sig: TypeTag.Sig,
pubkey: TypeTag.PubKey,
},
(sig, pubkey, ctx) => {
const nSequence = ctx.nSequence;
const nLockTime = ctx.nLockTime;
const sellerMatches = eq(hash160(pubkey), props.seller);
const sequenceIsNonFinal = neq(nSequence, finalizedSequenceSentinelHexLiteral());
const locktimeIsBlockHeight = isBlockHeightLocktime(nLockTime);
const locktimeMeetsCloseHeight = gte(nLockTime, props.closeHeight);
return {
outputs: [p2pkhOutput(ctx.value, props.seller)],
check: and(
sellerMatches,
and(
sequenceIsNonFinal,
and(
locktimeIsBlockHeight,
and(
locktimeMeetsCloseHeight,
checkSig(sig, pubkey),
),
),
),
),
};
},
),
}),
);
const NON_FINAL_INPUT_SEQUENCE = 0xffff_fffe;
const provider = createMemoryProvider();
const seller = createSigner();
const bidder = createSigner();
const sellerPubKey = await seller.getPublicKey();
const sellerPkh = await seller.getPublicKeyHash();
const bidderPkh = await bidder.getPublicKeyHash();
const closeHeight = 5n;
const auction = Auction({ seller: sellerPkh, closeHeight });
const closeAbi = auction.artifact.abi.find(
(entry) => entry.type === 'function' && entry.name === 'close',
);
const deployed = await auction.deploy({
highestBid: 1n,
highestBidder: sellerPkh,
}, {
provider,
satoshis: 1_000n,
});
// `bid()` has no signature check, so provider context is enough.
// Because it is stateful, the caller supplies the expected successor state.
const afterBid = await deployed.methods.bid.call(
{
newBid: 2n,
newBidder: bidderPkh,
},
{
provider,
nextState: {
highestBid: 2n,
highestBidder: bidderPkh,
},
},
);
// `close()` authenticates the seller, so it also passes signer and invoke.
// Terminal methods emit outputs directly, so there is no `nextState`.
const closed = await afterBid.nextInstance!.methods.close.call(
{ pubkey: sellerPubKey },
{
provider,
signer: seller,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: sellerPubKey }),
}),
txContext: {
inputSequence: NON_FINAL_INPUT_SEQUENCE,
locktime: Number(closeHeight),
},
},
);
const bidState = afterBid.nextInstance!.state;
const closeOutputs = closed.outputs;
console.log(bidState.highestBid); // 2n
console.log(closeAbi?.returnShape); // terminal
console.log(closeOutputs.length); // 1
Walkthrough 3: AtomicSwap P2PKH redirection
AtomicSwap is the cleanest terminal-output example in the repo because both
redeem and refund are terminal paths.
The spend no longer just validates the branch conditions; it also covenants the
full spent value into a P2PKH output chosen by that branch.
The payout code uses the same trailing context surface:
redeem: method(
{
sig: TypeTag.Sig,
pubkey: TypeTag.PubKey,
preimage: TypeTag.ByteString,
},
(sig, pubkey, preimage, ctx) => ({
outputs: [p2pkhOutput(ctx.value, props.recipient)],
check: and(
eq(hash160(pubkey), props.recipient),
eq(sha256(preimage), props.hashlock),
checkSig(sig, pubkey),
),
}),
),
refund: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey, ctx) => ({
outputs: [p2pkhOutput(ctx.value, props.refundPkh)],
check: and(
checkLocktime(props.timeout),
eq(hash160(pubkey), props.refundPkh),
checkSig(sig, pubkey),
),
}),
),
The local runtime flow below exercises both directions: redeem redirects the
UTXO to the recipient's P2PKH output, and a separate refund instance
redirects it back to the refund key after the locktime.
import {
and,
checkLocktime,
checkSig,
contract,
createMemoryProvider,
createSigner,
eq,
hash160,
method,
p2pkhOutput,
sha256,
TypeTag,
} from '@opcat-labs/lambit';
const AtomicSwap = contract(
'AtomicSwap',
{
recipient: TypeTag.Ripemd160,
refundPkh: TypeTag.Ripemd160,
hashlock: TypeTag.Sha256,
timeout: TypeTag.Int,
},
{},
({ props }) => ({
redeem: method(
{
sig: TypeTag.Sig,
pubkey: TypeTag.PubKey,
preimage: TypeTag.ByteString,
},
(sig, pubkey, preimage, ctx) => ({
outputs: [p2pkhOutput(ctx.value, props.recipient)],
check: and(
eq(hash160(pubkey), props.recipient),
eq(sha256(preimage), props.hashlock),
checkSig(sig, pubkey),
),
}),
),
refund: method(
{
sig: TypeTag.Sig,
pubkey: TypeTag.PubKey,
},
(sig, pubkey, ctx) => ({
outputs: [p2pkhOutput(ctx.value, props.refundPkh)],
check: and(
checkLocktime(props.timeout),
eq(hash160(pubkey), props.refundPkh),
checkSig(sig, pubkey),
),
}),
),
}),
);
const provider = createMemoryProvider();
const recipientSigner = createSigner();
const refundSigner = createSigner();
const recipientPubKey = await recipientSigner.getPublicKey();
const refundPubKey = await refundSigner.getPublicKey();
const recipientPkh = await recipientSigner.getPublicKeyHash();
const refundPkh = await refundSigner.getPublicKeyHash();
const timeout = 500n;
const preimage = '11'.repeat(32);
// precomputed: sha256(bytes('11'.repeat(32)))
const hashlock = '02d449a31fbb267c8f352e9968a79e3e5fc95c1bbeaa502fd6454ebde5a4bedc';
const redeemSwap = AtomicSwap({
recipient: recipientPkh,
refundPkh,
hashlock,
timeout,
});
const redeemDeployed = await redeemSwap.deploy({
provider,
satoshis: 10_000n,
});
const redeem = await redeemDeployed.methods.redeem.call(
{ pubkey: recipientPubKey, preimage },
{
provider,
signer: recipientSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: recipientPubKey }),
}),
},
);
const refundSwap = AtomicSwap({
recipient: recipientPkh,
refundPkh,
hashlock,
timeout,
});
const refundDeployed = await refundSwap.deploy({
provider,
satoshis: 10_000n,
});
const refund = await refundDeployed.methods.refund.call(
{ pubkey: refundPubKey },
{
provider,
signer: refundSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { publicKey: refundPubKey }),
}),
txContext: {
inputSequence: 0xffff_fffe,
locktime: Number(timeout),
},
},
);
const redeemOutputs = redeem.outputs;
const refundOutputs = refund.outputs;
console.log(redeemOutputs.length); // 1
console.log(refundOutputs.length); // 1
Pitfalls
- Script-size budget: each authored output covenant site adds real script
surface. Treat
~38bytes per committed output as a practical budget signal, then remember each serialized output also carries its own 8-byte amount field and script bytes. - State fields vs spent value: terminal payout branches should usually spend
ctx.value, not a mirrored state field. Only commitstate.amountdirectly when the contract proves that state always equals the spent UTXO value. - Sighash compatibility: native stateful successor paths and single-output
terminal paths use
SIGHASH_SINGLE, while selected multi-output terminal paths switch toSIGHASH_ALL. That is why wallet-funded providers reject selected multi-output terminal paths: added change would invalidate the committed output vector. - Empty output sets:
assertOutputs([])intentionally commits tohash256("")for low-level testing, but a real transaction still needs at least one output to be relayable and mineable.
For more on hashOutputs, preimage fields, and native-stateful sighash
selection, continue with Transaction Context.