Skip to main content

Tutorial 2: Auction

Overview

In this tutorial we will build an on-chain auction contract, test it locally, deploy it to the OP_CAT testnet, and walk through the complete bid and close lifecycle.

The auction is open and transparent: anyone can outbid the current leader, the auctioneer collects the winning amount when the deadline passes, and every outbid participant should receive their refund in the same transaction that supplants them.

There are two public methods:

MethodWho calls itWhenEffect
bidAny bidderBefore deadlineUpdates highest bidder and bid; old bidder gets refunded
closeAuctioneerAfter deadlineAuctioneer claims the highest bid

Before starting, ensure you have completed the Hello World tutorial.

Contract Properties

The auction tracks two categories of information.

Props — fixed at deploy time, embedded in the locking script bytecode:

PropTypeMeaning
auctioneerRipemd160HASH160 of the auctioneer's public key
auctionDeadlineIntBlock height (< 500 000 000) or Unix timestamp

State — updated on every successful bid, stored as a hash in the UTXO's data attachment:

State fieldTypeMeaning
highestBidderRipemd160HASH160 of the current highest bidder
highestBidIntCurrent highest bid in satoshis

When the auctioneer deploys the contract they lock the minimum opening bid into the UTXO and record themselves as the initial highest bidder.

The Stateful UTXO Model

Understanding the UTXO lifecycle is essential before reading the contract code.

Deploy:
TX_deploy
output[0]: Auction locking script, satoshis = openingBid
state = { highestBidder: auctioneerAddr, highestBid: openingBid }

Bid (Alice overbids the auctioneer):
TX_bid_1
input[0]: TX_deploy:vout0 ← spends the auction UTXO
input[1]: Alice's funding UTXO ← covers the new bid amount + fees
output[0]: Auction locking script ← new auction UTXO (SIGHASH_SINGLE commits this)
state = { highestBidder: aliceAddr, highestBid: aliceBid }
output[1]: P2PKH back to auctioneer ← refund of opening bid
output[2]: change back to Alice

Bid (Bob overbids Alice):
TX_bid_2
input[0]: TX_bid_1:vout0
output[0]: Auction locking script
state = { highestBidder: bobAddr, highestBid: bobBid }
output[1]: P2PKH back to Alice ← refund of Alice's bid

Close (auctioneer closes after deadline):
TX_close
input[0]: latest auction UTXO
output[0]: any address (auctioneer's wallet)

The locking script enforces two things per bid:

  1. The new bid strictly exceeds the recorded highestBid.
  2. The successor UTXO at the same output index carries the updated state.

The examples below use the runtime's SIGHASH_SINGLE approach for stateful transitions, so each bid commits to the successor auction output at the same output index.

Write the Contract

Create contracts/auction.ts:

import {
TypeTag,
and,
checkLocktime,
checkSig,
contract,
eq,
gt,
hash160,
method,
} from '@opcat-labs/lambit';

export const Auction = contract(
'Auction',

{
// HASH160 of the auctioneer's public key.
// Stored as Ripemd160 (20 bytes / 40 hex chars) so the close method can
// verify the caller's public key without storing the full key on-chain.
auctioneer: TypeTag.Ripemd160,

// Auction deadline as a Bitcoin-compatible locktime value.
// Values < 500_000_000 are interpreted as block heights.
// Values >= 500_000_000 are interpreted as Unix timestamps.
auctionDeadline: TypeTag.Int,
},

{
// HASH160 of the current highest bidder's public key.
// Initialized to the auctioneer's address at deploy time.
highestBidder: TypeTag.Ripemd160,

// current highest bid in satoshis.
// is the satoshi value locked in the UTXO at deploy time.
highestBid: TypeTag.Int,
},

({ props, state }) => ({

// Any participant may call this method before the auction deadline.
// The method params — newBid and newBidder — are supplied at spend time
bid: method(
{
newBid: TypeTag.Int,
newBidder: TypeTag.Ripemd160,
},

(newBid, newBidder) => ({
// `next` declares the successor state.
// The compiler generates a SIGHASH_SINGLE hashOutputs check that
// enforces the output at the same index as this input carries the
// same locking script and a data hash equal to stateHash(next).
next: {
highestBidder: newBidder,
highestBid: newBid,
},

// gt(newBid, state.highestBid):
// The new bid must strictly exceed the current highest bid.
// Note that `state.highestBid` is the value stored in the CURRENT
// UTXO's state hash — it is authenticated by the preimage prologue,
// so the spender cannot lie about what the current highest bid is.
check: gt(newBid, state.highestBid),
}),
),

// Only the auctioneer can close the auction, and only after the deadline.
// Once closed, the auctioneer may send the locked satoshis to any address.
close: method(
{
sig: TypeTag.Sig, // signature from the auctioneer
pubkey: TypeTag.PubKey, // public key
},

(sig, pubkey) =>
and(
// Verify the caller is the auctioneer:
// hash160(pubkey) must equal props.auctioneer (the stored HASH160).
eq(hash160(pubkey), props.auctioneer),

and(
// checkLocktime enforces nLockTime >= props.auctionDeadline using
// OP_CHECKLOCKTIMEVERIFY. The deadline must be consistent with the
// transaction's nSequence and nVersion fields.
checkLocktime(props.auctionDeadline),

// signature check against the auctioneer's key.
checkSig(sig, pubkey),
),
),
),
}),
);

Test the Contract

Replace test/auction.test.ts with:

import { expect } from 'chai';
import {
createMemoryProvider,
createSigner,
} from '@opcat-labs/lambit';
import { Auction } from '../contracts/auction';

// Auction deadline expressed as a block height.
// Values < 500_000_000 → block height; values >= 500_000_000 → Unix timestamp.
const AUCTION_DEADLINE = 800_000n;

// Opening bid: the auctioneer locks this amount when deploying.
// Both the UTXO satoshi value and highestBid state must match.
const OPENING_BID = 1_000n;


describe('Auction — pure state transitions', () => {
let auctioneerAddr: string;
let bidderAddr: string;

before(async () => {
auctioneerAddr = await createSigner().then((s) => s.getPublicKeyHash());
bidderAddr = await createSigner().then((s) => s.getPublicKeyHash());
});

it('computes the correct next state after a valid bid', () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: auctioneerAddr, highestBid: OPENING_BID };
const next = auction.methods.bid.next(
currentState,
{ newBid: 2_500n, newBidder: bidderAddr },
);

expect(next).to.deep.equal({ highestBidder: bidderAddr, highestBid: 2_500n });
});

it('chains multiple bid transitions correctly', () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const state0 = { highestBidder: auctioneerAddr, highestBid: OPENING_BID };
const state1 = auction.methods.bid.next(state0, { newBid: 2_000n, newBidder: bidderAddr });
const state2 = auction.methods.bid.next(state1, { newBid: 5_000n, newBidder: auctioneerAddr });

expect(state1.highestBid).to.equal(2_000n);
expect(state2.highestBid).to.equal(5_000n);
expect(state2.highestBidder).to.equal(auctioneerAddr);
});
});


describe('Auction', () => {
let auctioneerSigner: Awaited<ReturnType<typeof createSigner>>;
let auctioneerAddr: string;
let auctioneerPubKey: string;
let bidderAddr: string;

before(async () => {
auctioneerSigner = await createSigner();
auctioneerAddr = await auctioneerSigner.getPublicKeyHash();
auctioneerPubKey = await auctioneerSigner.getPublicKey();
bidderAddr = await createSigner().then((s) => s.getPublicKeyHash());
});

it('verifies a valid bid', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: auctioneerAddr, highestBid: OPENING_BID };
const nextState = auction.methods.bid.next(
currentState,
{ newBid: 2_500n, newBidder: bidderAddr },
);

// verify() for a stateful method receives:
// - currentState: the state being spent
// - method args: the witness values newBid and newBidder
// context.satoshis is the satoshi value of the UTXO being spent.
// context.nextState is what the Script will commit to in hashOutputs.
await auction.methods.bid.verify(
currentState,
{ newBid: 2_500n, newBidder: bidderAddr },
{
context: {
satoshis: OPENING_BID,
nextState,
},
},
);
});

it('rejects a bid that does not exceed the highest bid', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: auctioneerAddr, highestBid: OPENING_BID };
const attemptedState = auction.methods.bid.next(
currentState,
{ newBid: OPENING_BID, newBidder: bidderAddr },
);

await expect(
auction.methods.bid.verify(
currentState,
{ newBid: OPENING_BID, newBidder: bidderAddr },
{ context: { satoshis: OPENING_BID, nextState: attemptedState } },
),
).to.be.rejectedWith(/BVM verification failed/);
});

it('rejects a bid lower than the current highest', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: bidderAddr, highestBid: 5_000n };
const nextState = auction.methods.bid.next(
currentState,
{ newBid: 3_000n, newBidder: auctioneerAddr },
);

await expect(
auction.methods.bid.verify(
currentState,
{ newBid: 3_000n, newBidder: auctioneerAddr },
{ context: { satoshis: 5_000n, nextState } },
),
).to.be.rejectedWith(/BVM verification failed/);
});

it('verifies a valid close after the deadline', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: bidderAddr, highestBid: 5_000n };

// close is a signature-bearing method: verify() requires txid, vout,
// satoshis, nLockTime (>= deadline).
//
// nLockTime in the BVM context must be >= auctionDeadline for
// checkLocktime(props.auctionDeadline) to pass.
await auction.methods.close.verify(
currentState,
{ pubkey: auctioneerPubKey },
{
context: {
satoshis: 5_000n,
txid: 'aa'.repeat(32),
vout: 0,
nLockTime: AUCTION_DEADLINE, // exactly at the deadline — should pass
},
signer: auctioneerSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address: auctioneerAddr }),
}),
},
);
});

it('rejects a close before the deadline', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: bidderAddr, highestBid: 5_000n };

await expect(
auction.methods.close.verify(
currentState,
{ pubkey: auctioneerPubKey },
{
context: {
satoshis: 5_000n,
txid: 'aa'.repeat(32),
vout: 0,
nLockTime: AUCTION_DEADLINE - 1n, // one block before deadline
},
signer: auctioneerSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address: auctioneerAddr }),
}),
},
),
).to.be.rejectedWith(/BVM verification failed/);
});

it('rejects a close by a non-auctioneer key', async () => {
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const currentState = { highestBidder: bidderAddr, highestBid: 5_000n };
const impostorSigner = await createSigner();
const impostorPubKey = await impostorSigner.getPublicKey();
const impostorAddr = await impostorSigner.getPublicKeyHash();

await expect(
auction.methods.close.verify(
currentState,
{ pubkey: impostorPubKey },
{
context: {
satoshis: 5_000n,
txid: 'aa'.repeat(32),
vout: 0,
nLockTime: AUCTION_DEADLINE,
},
signer: impostorSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address: impostorAddr }),
}),
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
});

describe('Auction — MemoryProvider integration', () => {
let auctioneerSigner: Awaited<ReturnType<typeof createSigner>>;
let auctioneerAddr: string;
let auctioneerPubKey: string;
let bidderAddr: string;

before(async () => {
auctioneerSigner = await createSigner();
auctioneerAddr = await auctioneerSigner.getPublicKeyHash();
auctioneerPubKey = await auctioneerSigner.getPublicKey();
bidderAddr = await createSigner().then((s) => s.getPublicKeyHash());
});

it('deploys, accepts a bid, and closes after deadline', async () => {
const provider = createMemoryProvider();

const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

// The initial state records the auctioneer as the opening bidder.
const initialState = {
highestBidder: auctioneerAddr,
highestBid: OPENING_BID,
};

const deployed = await auction.deploy(initialState, {
provider,
satoshis: OPENING_BID,
});

// Confirm the UTXO exists in the memory provider.
const utxoAfterDeploy = await provider.getUtxo(
deployed.utxo.txid,
deployed.utxo.vout,
);
expect(utxoAfterDeploy).to.exist;
expect(utxoAfterDeploy!.satoshis).to.equal(OPENING_BID);

// first bid
const firstBidAmount = 2_500n;

const stateAfterFirstBid = auction.methods.bid.next(
initialState,
{ newBid: firstBidAmount, newBidder: bidderAddr },
);
expect(stateAfterFirstBid).to.deep.equal({
highestBidder: bidderAddr,
highestBid: firstBidAmount,
});

// call() builds the spending transaction,
// The deployed instance holds the current UTXO, so we do not
// need to pass the current state again — it is read from deployed.utxo.
const afterFirstBid = await deployed.methods.bid.call(
{ newBid: firstBidAmount, newBidder: bidderAddr },
{
provider,
nextState: stateAfterFirstBid,
},
);

// afterFirstBid.nextInstance is the new deployed instance
// with its UTXO pointing to the successor output.
expect(afterFirstBid.nextInstance).to.exist;
expect(afterFirstBid.nextInstance!.state).to.deep.equal(stateAfterFirstBid);

// The old UTXO is consumed.
const utxoAfterBid = await provider.getUtxo(
deployed.utxo.txid,
deployed.utxo.vout,
);
expect(utxoAfterBid).to.be.undefined;

const secondBidAmount = 7_000n;
const secondBidderAddr = await createSigner().then((s) => s.getPublicKeyHash());

const stateAfterSecondBid = auction.methods.bid.next(
stateAfterFirstBid,
{ newBid: secondBidAmount, newBidder: secondBidderAddr },
);

const afterSecondBid = await afterFirstBid.nextInstance!.methods.bid.call(
{ newBid: secondBidAmount, newBidder: secondBidderAddr },
{
provider,
nextState: stateAfterSecondBid,
},
);
expect(afterSecondBid.nextInstance!.state.highestBid).to.equal(secondBidAmount);

// close() is a terminal method — it consumes the UTXO without creating a
// successor. The auctioneer may route the funds to any address
const closeResult = await afterSecondBid.nextInstance!.methods.close.call(
{ pubkey: auctioneerPubKey },
{
provider,
signer: auctioneerSigner,
invoke: (psbt) => ({
sig: psbt.getSig(0, { address: auctioneerAddr }),
}),
// nLockTime must be >= auctionDeadline for checkLocktime to pass.
nLockTime: AUCTION_DEADLINE,
},
);

expect(closeResult.txid).to.be.a('string').with.length(64);
// nextInstance is undefined, the auction UTXO is consumed, not continued.
expect(closeResult.nextInstance).to.be.undefined;
});

it('rejects a bid lower than the current highest under MemoryProvider', async () => {
const provider = createMemoryProvider();
const auction = Auction({
auctioneer: auctioneerAddr,
auctionDeadline: AUCTION_DEADLINE,
});

const initialState = { highestBidder: auctioneerAddr, highestBid: 5_000n };
const deployed = await auction.deploy(initialState, { provider, satoshis: 5_000n });

const badNextState = auction.methods.bid.next(
initialState,
{ newBid: 3_000n, newBidder: bidderAddr },
);

await expect(
deployed.methods.bid.call(
{ newBid: 3_000n, newBidder: bidderAddr },
{ provider, nextState: badNextState },
),
).to.be.rejectedWith(/BVM verification failed/);

const utxo = await provider.getUtxo(deployed.utxo.txid, deployed.utxo.vout);
expect(utxo).to.exist;
});
});

Run the tests:

npx mocha --no-config --require tsx test/auction.test.ts

What's Next

  • Script Context — understand how ctx.value, ctx.nLockTime, and hashOutputs flow from the sighash preimage into your contract logic
  • Output Covenants — learn how assertOutputs() and the SIGHASH_ALL path can commit a full output vector when you need stronger refund guarantees
  • Stateful Contracts — deeper explanation of state hashing, the commitment prologue, and SIGHASH_SINGLE vs SIGHASH_ALL selection
  • Voting Tutorial — stateful vote counting with conditional state updates and successor-output commitments