Tutorial 3: Voting
Overview
In this tutorial we will build a voting dApp (contract) on Bitcoin.
Smart contract — an on-chain Voting contract that stores candidate
names and their vote tallies, accepting one vote per call and propagating
the updated state in the successor UTXO.
There is one public method:
| Method | Who calls it | Effect |
|---|---|---|
vote | Any voter | Increments the matching candidate's tally by one; propagates new state |
Before starting, ensure all prerequisite tools are installed and that you have completed the Hello World tutorial.
Contract
Design: Props vs. State
Candidate names are fixed at deploy time — they cannot change during the election. They belong in props, which are baked into the locking script.
Candidate vote tallies change on every vote. They belong in state, which is stored as a hash in the UTXO's data attachment and updated on each spend.
| Field | Kind | Type | Meaning |
|---|---|---|---|
nameA | Prop | ByteString | UTF-8 hex encoding of candidate A's name |
nameB | Prop | ByteString | UTF-8 hex encoding of candidate B's name |
votesA | State | Int | Votes received by candidate A so far |
votesB | State | Int | Votes received by candidate B so far |
The contract is initialised with votesA = 0 and votesB = 0.
The N = 2 Constraint
The original sCrypt tutorial used a FixedArray<Candidate, N> with a loop.
Lambit does not have a native fixed-array state type: every state field must
be a named scalar. With exactly two candidates the mapping is direct —
votesA and votesB are explicit state fields.
If you need more candidates, extend the state schema with votesC,
votesD, etc., and add the corresponding cond(...) branches inside
vote. The compile-time unrolling model makes the trade-off explicit: each
candidate adds one cond branch to the Script bytecode.
Properties
// Two candidate names — fixed at deploy time, embedded in the locking script.
nameA: TypeTag.ByteString,
nameB: TypeTag.ByteString,
// Two vote tallies — updated on every vote, stored as a state hash.
{ votesA: TypeTag.Int, votesB: TypeTag.Int }
The vote Method
The voter provides the name of the candidate they are voting for. The method:
- Uses
cond()to conditionally increment each tally — if the provided name matchesprops.nameA,votesAis incremented; otherwise it stays the same. The same logic applies tovotesB. - Checks that the provided name matches at least one of the two candidates.
- Returns
{ next }— the successor state that gets committed in the spending transaction output via the compiledhashOutputscheck.
vote(candidateName):
nextVotesA = cond(candidateName == nameA, votesA + 1, votesA)
nextVotesB = cond(candidateName == nameB, votesB + 1, votesB)
next = { votesA: nextVotesA, votesB: nextVotesB }
check = (candidateName == nameA || candidateName == nameB)
The Stateful UTXO Lifecycle
Deploy:
TX_deploy
output[0]: Voting locking script, satoshis = BALLOT_FEE
state = { votesA: 0n, votesB: 0n }
Vote (Alice votes for candidate A):
TX_vote_1
input[0]: TX_deploy:vout0 ← spends the current Voting UTXO
output[0]: Voting locking script ← new Voting UTXO (SIGHASH_SINGLE)
state = { votesA: 1n, votesB: 0n }
output[1]: change back to Alice
Vote (Bob votes for candidate B):
TX_vote_2
input[0]: TX_vote_1:vout0
output[0]: Voting locking script
state = { votesA: 1n, votesB: 1n }
...
The locking script enforces two things per vote:
- The candidate name must equal
nameAornameB. - The successor UTXO at the same output index carries the updated state.
Write the Contract
import {
TypeTag,
add,
cond,
contract,
eq,
lit,
method,
or,
} from '@opcat-labs/lambit';
// N = 2 candidates are supported. Candidate names are props (fixed at deploy
// time); vote tallies are state (updated on every spend).
//
// To support more candidates, add nameC / votesC props+state fields and extend
// the cond(...) expressions in the vote method below.
export const Voting = contract(
'Voting',
// Props: candidate names, fixed in the locking script bytecode
//
// TypeTag.ByteString — raw bytes. In practice these are the UTF-8 byte
// sequences of the candidate name strings, represented as hex in TypeScript:
// Buffer.from('iPhone', 'utf8').toString('hex') → '6950686f6e65'
//
// The voter must supply this exact hex sequence when calling vote().
{
nameA: TypeTag.ByteString,
nameB: TypeTag.ByteString,
},
// State: vote tallies, updated on every spend
{
votesA: TypeTag.Int,
votesB: TypeTag.Int,
},
({ props, state }) => ({
// vote
//
// Any voter may call this method at any time. There is no per-voter
// authentication: the contract is open voting.
//
// Production note: to restrict voting to a registered voter set, add
// a Merkle-path proof or a signature check against a voter registry
// embedded in the props.
vote: method(
// The voter supplies the name of the candidate they are voting for,
// as the same UTF-8 hex bytes that were embedded at deploy time.
{ candidate: TypeTag.ByteString },
(candidate) => {
// Conditional increment
//
// `cond(condition, ifTrue, ifFalse)` lowers to OP_IF ... OP_ELSE in
// the compiled Script. Both branches are statically present in the
// bytecode; the condition is evaluated at spend time from the witness.
//
// nextVotesA: if the voter chose A, increment by 1; otherwise keep.
const nextVotesA = cond(
eq(candidate, props.nameA),
add(state.votesA, lit(1n)),
state.votesA,
);
// nextVotesB: if the voter chose B, increment by 1; otherwise keep.
const nextVotesB = cond(
eq(candidate, props.nameB),
add(state.votesB, lit(1n)),
state.votesB,
);
return {
// `next` declares the successor state.
// Both fields must be present — the compiler throws if any state
// field is missing from the next-state object.
next: {
votesA: nextVotesA,
votesB: nextVotesB,
},
// `check` is an additional boolean predicate evaluated alongside
// the compiled output commitment.
//
// Reject votes for unknown candidates. Without this check a voter
// could supply an arbitrary name that matches neither A nor B —
// both cond branches would fall through to the "keep" path,
// producing a no-op spend that still consumes a UTXO.
check: or(
eq(candidate, props.nameA),
eq(candidate, props.nameB),
),
};
},
),
}),
);
What the Compiled Script Enforces
Locking script (simplified, vote path):
Assemble sighash preimage parts via OP_CAT chains
hash(assembled_preimage) must equal the Schnorr commitment ← authenticates ctx
hash(witness_state_cleartext) must equal ctx.spentDataHash ← proves state
── Application logic
candidate == nameA OR candidate == nameB ← check: or(...)
── Conditional state update
nextVotesA = (candidate == nameA) ? votesA + 1 : votesA ← cond(...)
nextVotesB = (candidate == nameB) ? votesB + 1 : votesB ← cond(...)
── Output commitment (SIGHASH_SINGLE)
ctx.hashOutputs must equal
hash256(
spentScriptHash ← same locking script on successor
|| nextSatoshis ← same value at same output index
|| stateHash(next) ← hash of { votesA: nextVotesA, votesB: nextVotesB }
)
Final Code
The complete contracts/voting.ts is the file you already wrote above:
import {
TypeTag,
add,
cond,
contract,
eq,
lit,
method,
or,
} from '@opcat-labs/lambit';
export const Voting = contract(
'Voting',
{
nameA: TypeTag.ByteString,
nameB: TypeTag.ByteString,
},
{
votesA: TypeTag.Int,
votesB: TypeTag.Int,
},
({ props, state }) => ({
vote: method(
{ candidate: TypeTag.ByteString },
(candidate) => {
const nextVotesA = cond(
eq(candidate, props.nameA),
add(state.votesA, lit(1n)),
state.votesA,
);
const nextVotesB = cond(
eq(candidate, props.nameB),
add(state.votesB, lit(1n)),
state.votesB,
);
return {
next: { votesA: nextVotesA, votesB: nextVotesB },
check: or(eq(candidate, props.nameA), eq(candidate, props.nameB)),
};
},
),
}),
);
Inspect the Artifact
Add a scratch file to confirm what the compiler produces:
// scripts/inspect.ts
import { buildArtifact } from '@opcat-labs/lambit';
import { Voting } from '../contracts/voting';
const artifact = buildArtifact(Voting);
console.log('Contract name:', artifact.contract);
// "Voting"
console.log('ABI:');
console.dir(artifact.abi, { depth: null });
// [
// { type: 'function', name: 'vote',
// params: [{ name: 'candidate', type: 'bytes' }],
// returnShape: 'stateful' },
// { type: 'constructor',
// params: [{ name: 'nameA', type: 'bytes' }, { name: 'nameB', type: 'bytes' }] }
// ]
console.log('State props:', artifact.stateProps);
// [{ name: 'votesA', type: 'int' }, { name: 'votesB', type: 'int' }]
// Verify pure state transitions before running any BVM tests.
// Candidate names are UTF-8 bytes represented as hex strings.
const NAME_A = Buffer.from('iPhone', 'utf8').toString('hex'); // '6950686f6e65'
const NAME_B = Buffer.from('Android', 'utf8').toString('hex'); // '416e64726f6964'
const voting = Voting({ nameA: NAME_A, nameB: NAME_B });
const s0 = { votesA: 0n, votesB: 0n };
// Vote for A
const s1 = voting.methods.vote.next(s0, { candidate: NAME_A });
console.log('After voting A:', s1);
// { votesA: 1n, votesB: 0n }
// Vote for B
const s2 = voting.methods.vote.next(s1, { candidate: NAME_B });
console.log('After voting B:', s2);
// { votesA: 1n, votesB: 1n }
// Vote for A again
const s3 = voting.methods.vote.next(s2, { candidate: NAME_A });
console.log('After second vote A:', s3);
// { votesA: 2n, votesB: 1n }
Run it:
npx tsx scripts/inspect.ts
.next() evaluates the state transition expression tree in TypeScript with no
Script involved. Verify your cond logic here before writing BVM tests.
Test the Contract
Replace test/voting.test.ts with:
import { expect } from 'chai';
import {
buildArtifact,
createMemoryProvider,
createSigner,
} from '@opcat-labs/lambit';
import { Voting } from '../contracts/voting';
// Candidate names as UTF-8 hex bytes.
// Bitcoin Script has no string type — all values are byte vectors.
const NAME_A = Buffer.from('iPhone', 'utf8').toString('hex');
// '6950686f6e65'
const NAME_B = Buffer.from('Android', 'utf8').toString('hex');
// '416e64726f6964'
// A name that matches neither candidate — used to test rejection.
const NAME_UNKNOWN = Buffer.from('Windows', 'utf8').toString('hex');
// The initial state: both tallies start at zero.
const INITIAL_STATE = { votesA: 0n, votesB: 0n };
// Satoshis locked in the contract UTXO.
// The value is preserved on every vote; it is the "ballot fee" that funds
// future state transitions.
const BALLOT_FEE = 1_000n;
// Compile-only checks. No BVM, no network.
describe('Voting — artifact', () => {
it('has the correct contract name and state props', () => {
const artifact = buildArtifact(Voting);
expect(artifact.contract).to.equal('Voting');
expect(artifact.stateProps.map((p) => p.name)).to.deep.equal([
'votesA',
'votesB',
]);
});
it('exposes a single public method: vote', () => {
const artifact = buildArtifact(Voting);
const fns = artifact.abi.filter((e) => e.type === 'function');
expect(fns).to.have.length(1);
expect(fns[0]!.name).to.equal('vote');
});
});
// Always start here. .next() runs in TypeScript — no BVM, no network.
// If the cond() logic is wrong you catch it immediately.
describe('Voting — pure state transitions', () => {
let voting: ReturnType<typeof Voting>;
before(() => {
voting = Voting({ nameA: NAME_A, nameB: NAME_B });
});
it('increments votesA when candidate A is chosen', () => {
const next = voting.methods.vote.next(INITIAL_STATE, { candidate: NAME_A });
expect(next).to.deep.equal({ votesA: 1n, votesB: 0n });
});
it('increments votesB when candidate B is chosen', () => {
const next = voting.methods.vote.next(INITIAL_STATE, { candidate: NAME_B });
expect(next).to.deep.equal({ votesA: 0n, votesB: 1n });
});
it('correctly accumulates votes over multiple rounds', () => {
const s0 = INITIAL_STATE;
const s1 = voting.methods.vote.next(s0, { candidate: NAME_A });
const s2 = voting.methods.vote.next(s1, { candidate: NAME_B });
const s3 = voting.methods.vote.next(s2, { candidate: NAME_A });
const s4 = voting.methods.vote.next(s3, { candidate: NAME_A });
expect(s4).to.deep.equal({ votesA: 3n, votesB: 1n });
});
it('does not change any tally when the unknown name passes through cond()', () => {
// cond() only falls through to the "keep" path — the check: or(...) in
// the Script would reject this. Here we are testing the raw transition
// arithmetic, not the Script check.
const next = voting.methods.vote.next(INITIAL_STATE, { candidate: NAME_UNKNOWN });
expect(next).to.deep.equal({ votesA: 0n, votesB: 0n });
});
});
// verify() runs the compiled locking script + witness through the local
// Bitcoin VM. No provider, no network, no coins needed.
describe('Voting — local BVM verification', () => {
let voting: ReturnType<typeof Voting>;
before(() => {
voting = Voting({ nameA: NAME_A, nameB: NAME_B });
});
it('verifies a valid vote for candidate A', async () => {
const currentState = INITIAL_STATE;
const nextState = voting.methods.vote.next(currentState, { candidate: NAME_A });
// verify() for a stateful method receives:
// - currentState: the state stored in the UTXO being spent
// - method args: what the voter provides in the witness
// - context: BVM-level spend environment
//
// context.satoshis is the value of the UTXO being spent.
// context.nextState is what the Script commits to in hashOutputs.
await voting.methods.vote.verify(
currentState,
{ candidate: NAME_A },
{
context: {
satoshis: BALLOT_FEE,
nextState,
},
},
);
// Resolves without throwing → BVM accepted the spend.
});
it('verifies a valid vote for candidate B', async () => {
const currentState = { votesA: 3n, votesB: 1n };
const nextState = voting.methods.vote.next(currentState, { candidate: NAME_B });
await voting.methods.vote.verify(
currentState,
{ candidate: NAME_B },
{ context: { satoshis: BALLOT_FEE, nextState } },
);
});
it('rejects a vote for an unknown candidate', async () => {
const currentState = INITIAL_STATE;
const nextState = voting.methods.vote.next(currentState, { candidate: NAME_UNKNOWN });
// The check: or(...) predicate evaluates false → BVM rejects the spend.
await expect(
voting.methods.vote.verify(
currentState,
{ candidate: NAME_UNKNOWN },
{ context: { satoshis: BALLOT_FEE, nextState } },
),
).to.be.rejectedWith(/BVM verification failed/);
});
it('rejects a tampered nextState', async () => {
const currentState = INITIAL_STATE;
// Voter claims to vote for A, but the nextState they commit to
// gives the votes to B instead — a fraud attempt.
const tamperedNextState = { votesA: 0n, votesB: 1n }; // wrong
await expect(
voting.methods.vote.verify(
currentState,
{ candidate: NAME_A },
{ context: { satoshis: BALLOT_FEE, nextState: tamperedNextState } },
),
).to.be.rejectedWith(/BVM verification failed/);
});
it('rejects a vote that tries to increment both tallies at once', async () => {
const currentState = INITIAL_STATE;
const maliciousNextState = { votesA: 1n, votesB: 1n }; // both +1
await expect(
voting.methods.vote.verify(
currentState,
{ candidate: NAME_A }, // claims to vote for A
{ context: { satoshis: BALLOT_FEE, nextState: maliciousNextState } },
),
).to.be.rejectedWith(/BVM verification failed/);
});
});
// After verify() passes, use createMemoryProvider() to exercise the full
// deploy/call cycle without touching the network.
describe('Voting — MemoryProvider integration', () => {
it('deploys and accumulates votes across multiple calls', async () => {
const provider = createMemoryProvider();
const voting = Voting({ nameA: NAME_A, nameB: NAME_B });
// ─ Deploy
// Deploy with the initial state (all tallies at zero).
// The satoshis value is the "ballot fee" carried by the contract UTXO and
// preserved on every vote.
const deployed = await voting.deploy(INITIAL_STATE, {
provider,
satoshis: BALLOT_FEE,
});
const utxo0 = await provider.getUtxo(deployed.utxo.txid, deployed.utxo.vout);
expect(utxo0).to.exist;
expect(utxo0!.satoshis).to.equal(BALLOT_FEE);
// ─ Vote 1: vote for A
const state0 = INITIAL_STATE;
const state1 = voting.methods.vote.next(state0, { candidate: NAME_A });
expect(state1).to.deep.equal({ votesA: 1n, votesB: 0n });
// call() builds the spending transaction, verifies the Script locally,
// then applies the spend to the in-memory UTXO set.
const result1 = await deployed.methods.vote.call(
{ candidate: NAME_A },
{ provider, nextState: state1 },
);
expect(result1.nextInstance).to.exist;
expect(result1.nextInstance!.state).to.deep.equal(state1);
// The original UTXO is consumed.
const utxo0after = await provider.getUtxo(deployed.utxo.txid, deployed.utxo.vout);
expect(utxo0after).to.be.undefined;
// ─ Vote 2: vote for B
const state2 = voting.methods.vote.next(state1, { candidate: NAME_B });
expect(state2).to.deep.equal({ votesA: 1n, votesB: 1n });
const result2 = await result1.nextInstance!.methods.vote.call(
{ candidate: NAME_B },
{ provider, nextState: state2 },
);
expect(result2.nextInstance!.state).to.deep.equal(state2);
// ─ Vote 3: vote for A again
const state3 = voting.methods.vote.next(state2, { candidate: NAME_A });
const result3 = await result2.nextInstance!.methods.vote.call(
{ candidate: NAME_A },
{ provider, nextState: state3 },
);
expect(result3.nextInstance!.state).to.deep.equal({ votesA: 2n, votesB: 1n });
});
it('rejects a vote for an unknown candidate under MemoryProvider', async () => {
const provider = createMemoryProvider();
const voting = Voting({ nameA: NAME_A, nameB: NAME_B });
const deployed = await voting.deploy(INITIAL_STATE, {
provider,
satoshis: BALLOT_FEE,
});
const badNextState = voting.methods.vote.next(INITIAL_STATE, {
candidate: NAME_UNKNOWN,
});
await expect(
deployed.methods.vote.call(
{ candidate: NAME_UNKNOWN },
{ provider, nextState: badNextState },
),
).to.be.rejectedWith(/BVM verification failed/);
// The UTXO is still present — failed spends are never applied.
const utxo = await provider.getUtxo(deployed.utxo.txid, deployed.utxo.vout);
expect(utxo).to.exist;
});
it('preserves the satoshi value across multiple votes', async () => {
const provider = createMemoryProvider();
const voting = Voting({ nameA: NAME_A, nameB: NAME_B });
let current = await voting.deploy(INITIAL_STATE, {
provider,
satoshis: BALLOT_FEE,
});
for (const candidate of [NAME_A, NAME_B, NAME_A, NAME_B]) {
const currentState = current.state ?? INITIAL_STATE;
const nextState = voting.methods.vote.next(currentState, { candidate });
const result = await current.methods.vote.call(
{ candidate },
{ provider, nextState },
);
current = result.nextInstance!;
}
// After 4 votes the UTXO still carries the same ballot fee.
const finalUtxo = await provider.getUtxo(current.utxo.txid, current.utxo.vout);
expect(finalUtxo!.satoshis).to.equal(BALLOT_FEE);
expect(current.state).to.deep.equal({ votesA: 2n, votesB: 2n });
});
});
Run the tests:
npx mocha --no-config --require tsx test/voting.test.ts
All tests should pass without touching the network.
Contract Deployment
Create scripts/deploy.ts:
// scripts/deploy.ts
//
// Deploys the Voting contract to the OP_CAT testnet and casts two test votes.
//
// Run:
// TESTNET_WIF=<your_wif> npx tsx scripts/deploy.ts
import {
createTestnetProvider,
createWifSigner,
getWifAddress,
} from '@opcat-labs/lambit';
import { Voting } from '../contracts/voting';
// Guard
const TESTNET_WIF = process.env.TESTNET_WIF;
if (!TESTNET_WIF) {
throw new Error('Set TESTNET_WIF before running this script.');
}
// Candidate names
// Candidate names as UTF-8 hex bytes.
// These are baked into the locking script bytecode at deploy time.
// Voters must supply the exact same hex sequence when calling vote().
const NAME_A = Buffer.from('iPhone', 'utf8').toString('hex');
const NAME_B = Buffer.from('Android', 'utf8').toString('hex');
// Provider and signer
const signer = createWifSigner(TESTNET_WIF, 'testnet');
const provider = createTestnetProvider({ wif: TESTNET_WIF, network: 'testnet' });
console.log('Funding address:', getWifAddress(TESTNET_WIF, 'testnet'));
// Bind the contract
// Calling Voting(props) substitutes the concrete name bytes into the locking
// script template and returns a bound contract ready for deployment.
const voting = Voting({ nameA: NAME_A, nameB: NAME_B });
console.log('\nLocking script:', voting.artifact.hex);
// Deploy
console.log('\n=== Deploying Voting contract ===');
// The ballot fee is the satoshi value locked in the contract UTXO.
// It is preserved through every vote and returned to the deployer when
// the election ends (extend the contract with a `close` method for that).
const BALLOT_FEE = 1_000n;
const INITIAL_STATE = { votesA: 0n, votesB: 0n };
const deployed = await voting.deploy(INITIAL_STATE, {
provider,
satoshis: BALLOT_FEE,
});
console.log(`Voting contract deployed: ${deployed.utxo.txid}:${deployed.utxo.vout}`);
console.log(`Ballot fee locked: ${deployed.utxo.satoshis} satoshis`);
console.log('Initial state:', INITIAL_STATE);
// Test vote 1: vote for iPhone
console.log('\n=== Casting vote for iPhone ===');
const state1 = voting.methods.vote.next(INITIAL_STATE, { candidate: NAME_A });
console.log('Next state will be:', state1);
const result1 = await deployed.methods.vote.call(
{ candidate: NAME_A },
{ provider, nextState: state1 },
);
console.log(`Vote cast: ${result1.txid}`);
console.log('New state:', result1.nextInstance!.state);
// Test vote 2: vote for Android
console.log('\n=== Casting vote for Android ===');
const state2 = voting.methods.vote.next(state1, { candidate: NAME_B });
const result2 = await result1.nextInstance!.methods.vote.call(
{ candidate: NAME_B },
{ provider, nextState: state2 },
);
console.log(`Vote cast: ${result2.txid}`);
console.log('New state:', result2.nextInstance!.state);
const contractId = {
txId: deployed.utxo.txid,
outputIndex: deployed.utxo.vout,
};
console.log('\n=== Deployment complete ===');
console.log('Contract ID:', JSON.stringify(contractId, null, 2));
console.log(`Explorer: https://explorer.opcat-testnet.io/tx/${deployed.utxo.txid}`);
Run it:
TESTNET_WIF=<your_wif> npx tsx scripts/deploy.ts
Expected output:
Funding address: <your testnet address>
Locking script: <compiled hex>
=== Deploying Voting contract ===
Voting contract deployed: <deploy_txid>:0
Ballot fee locked: 1000 satoshis
Initial state: { votesA: 0n, votesB: 0n }
=== Casting vote for iPhone ===
Next state will be: { votesA: 1n, votesB: 0n }
Vote cast: <vote1_txid>
New state: { votesA: 1n, votesB: 0n }
=== Casting vote for Android ===
Vote cast: <vote2_txid>
New state: { votesA: 1n, votesB: 1n }
=== Deployment complete ===
Contract ID: {
"txId": "<deploy_txid>",
"outputIndex": 0
}
Note the contract_id object in the output — you will need it for the frontend.
npm test does not run the testnet suite. Set BITCOIN_FP_RUN_TESTNET_E2E=1 and
a funded TESTNET_WIF only when you intend to spend testnet coins.
Transaction Anatomy
Deploy Transaction
TX_deploy:
inputs:
[0] deployer's wallet UTXO (funding)
outputs:
[0] Voting locking script, 1000 sat ← contract UTXO
data = stateHash({ votesA: 0n, votesB: 0n })
[1] P2WPKH change back to deployer
Vote Transaction
TX_vote:
inputs:
[0] previous Voting UTXO
witness:
preimage parts (assembled by stateCompile prologue via OP_CAT)
currentState cleartext = encode({ votesA: N, votesB: M })
candidate = <utf8 hex of chosen name>
outputs:
[0] Voting locking script ← new Voting UTXO (SIGHASH_SINGLE commits this)
data = stateHash({ votesA: N', votesB: M' })
[1] P2WPKH change back to voter