Tutorial 1: Hello World
Overview
In this tutorial we will write a Hashlock contract, test it locally, deploy
it to the OP_CAT testnet, and call it.
A Hashlock locks funds behind a SHA-256 preimage challenge. Only a spender
who can produce the original message that hashes to the stored value can unlock
the UTXO. It is the simplest possible contract that still demonstrates every
stage of the lambit lifecycle: authoring, local verification, deployment,
and spending.
Before starting, ensure all prerequisite tools are installed.
Create a New Project
Run the following commands to scaffold a new project:
npm install @opcat-labs/lambit
npx lambit scaffold ./hashlock
cd ./hashlock
npm test
The scaffold generates this layout:
hashlock/ ├── contracts/ │ └── hashlock.ts ├── test/ │ └── hashlock.test.ts ├── scripts/ │ └── deploy.ts └── package.json
Write the Contract
Replace the contents of contracts/hashlock.ts with:
import {
TypeTag,
assert,
contract,
eq,
method,
sha256,
} from '@opcat-labs/lambit';
export const Hashlock = contract(
// Contract name — appears in artifact.contract and the ABI.
'Hashlock',
// Props: values baked into the locking script at deploy time.
// The spender cannot change these; they are embedded in the bytecode.
{ hashlock: TypeTag.Sha256 },
({ props }) => ({
// unlock is the only public method — the only way to spend this UTXO.
unlock: method(
// Method params: values the spender supplies at spend time via the witness.
{ message: TypeTag.ByteString },
// `message` is an ExprNode proxy — it represents whatever the spender
// pushes in the transaction witness. It does not hold a value here;
// it describes a slot in the compiled Bitcoin Script.
(message) =>
// assert() wraps the predicate in a named verify boundary.
// The compiled script performs:
// OP_SHA256 <message> → hash the witness value
// <hashlock bytes> → push the stored hash
// OP_EQUAL → compare
// OP_VERIFY → fail the script if false
// OP_1 → signal success
assert(eq(sha256(message), props.hashlock)),
),
}),
);
Now let's look at the key concepts:
contract(): defines a named contract template. It is a pure function that runs once at compile time and produces an expression tree. Nothing in the body executes at spend time — the compiled bytecode does.- Props (
{ hashlock: TypeTag.Sha256 }): values fixed at deploy time and embedded in the locking script. ASha256prop is a 32-byte (64 hex character) value. method(): declares a public spending path. Its first argument is the param schema; its second is a function that receives typedExprNodeproxies and returns the boolean predicate the Bitcoin Script VM must evaluate totruefor the spend to succeed.assert(): wraps any boolean expression in an explicit verify boundary. It emitsOP_VERIFYin the compiled script and names the site in the ABI for debugging. You can also return a plaineq(...)withoutassert()— both produce a valid locking script.
Compile the Contract
lambit compiles contracts programmatically. You do not need a separate
compilation step — calling Hashlock(props) produces the artifact
automatically. To inspect the artifact at any time:
import { buildArtifact } from '@opcat-labs/lambit';
import { Hashlock } from './contracts/hashlock';
const artifact = buildArtifact(Hashlock);
console.log(artifact.contract); // Hashlock
console.log(artifact.abi); // [{ type: 'function', name: 'unlock', ... }]
console.log(artifact.hex); // locking script template with <hashlock> placeholder
Binding props substitutes the placeholder and produces the final deployable script:
import { createHash } from 'node:crypto';
import { Hashlock } from './contracts/hashlock';
const HASHLOCK = createHash('sha256')
.update(Buffer.from('hello world', 'utf8'))
.digest('hex');
// b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576f9d9e5d5a5a6e8d0
const hashlock = Hashlock({ hashlock: HASHLOCK });
console.log(hashlock.artifact.hex);
To compile to a standalone artifact file, use the CLI:
npx lambit artifact ./contracts/hashlock.ts --export Hashlock --output artifacts/Hashlock.json
Test the Contract
lambit has a two-tier testing model:
verify()— runs the compiled locking script through the local Bitcoin VM against a synthetic spend context. No provider, no network, no coins required. Use it first.createMemoryProvider()— runs the full deploy/call cycle in memory. Same runtime, same transaction builder, no network.
Always write both. Start with verify() because failures are instant and
deterministic.
Replace the contents of test/hashlock.test.ts with:
import { expect } from 'chai';
import { createHash } from 'node:crypto';
import { createMemoryProvider } from '@opcat-labs/lambit';
import { Hashlock } from '../contracts/hashlock';
// ── Fixtures ─────────────────────────────────────────────────────────────────
// The secret message encoded as UTF-8 hex bytes.
// Bitcoin Script has no string type — all values on the stack are byte vectors.
const MESSAGE_HEX = Buffer.from('hello world', 'utf8').toString('hex');
// 68656c6c6f20776f726c64
// The expected SHA-256 hash — baked into the locking script at deploy time.
const HASHLOCK = createHash('sha256')
.update(Buffer.from('hello world', 'utf8'))
.digest('hex');
// b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576f9d9e5d5a5a6e8d0
// A wrong message — used to confirm rejection.
const WRONG_MESSAGE_HEX = Buffer.from('goodbye world', 'utf8').toString('hex');
// Bind props. `hashlock` is a bound contract: its locking script is fixed
// and it exposes typed `.methods` for testing and deployment.
const hashlock = Hashlock({ hashlock: HASHLOCK });
describe('Hashlock — local BVM verification', () => {
it('verifies the unlock method with the correct message', async () => {
// verify() runs the compiled script + witness through the local Bitcoin VM.
// It needs no provider, no wallet, and no network access.
// A synthetic context supplies the spend environment the VM expects.
await hashlock.methods.unlock.verify(
{ message: MESSAGE_HEX },
{
context: {
satoshis: 1_000n,
txid: 'aa'.repeat(32),
vout: 0,
},
// No `signer` or `invoke` — Hashlock has no signature check.
// The preimage alone is the spending credential.
},
);
});
it('rejects a wrong message', async () => {
await expect(
hashlock.methods.unlock.verify(
{ message: WRONG_MESSAGE_HEX },
{ context: { satoshis: 1_000n, txid: 'aa'.repeat(32), vout: 0 } },
),
).to.be.rejectedWith(/BVM verification failed/);
});
});
describe('Hashlock — MemoryProvider integration', () => {
it('deploys and spends with the correct message', async () => {
const provider = createMemoryProvider();
// deploy() funds a UTXO locked by the Hashlock locking script.
// The provider handles UTXO creation internally.
const deployed = await hashlock.deploy({
provider,
satoshis: 1_000n,
});
const utxoBefore = await provider.getUtxo(
deployed.utxo.txid,
deployed.utxo.vout,
);
expect(utxoBefore).to.exist;
// call() builds the spending transaction, verifies the script locally,
// then applies the spend to the in-memory provider state.
await deployed.methods.unlock.call(
{ message: MESSAGE_HEX },
{ provider },
);
// The UTXO has been consumed — it no longer exists in the provider.
const utxoAfter = await provider.getUtxo(
deployed.utxo.txid,
deployed.utxo.vout,
);
expect(utxoAfter).to.be.undefined;
});
it('fails to spend with a wrong message', async () => {
const provider = createMemoryProvider();
const deployed = await hashlock.deploy({ provider, satoshis: 1_000n });
await expect(
deployed.methods.unlock.call(
{ message: WRONG_MESSAGE_HEX },
{ provider },
),
).to.be.rejectedWith(/BVM verification failed/);
// A failed spend is never applied — the UTXO is still present.
const utxoStillPresent = await provider.getUtxo(
deployed.utxo.txid,
deployed.utxo.vout,
);
expect(utxoStillPresent).to.exist;
});
});
Run the tests:
npx mocha --no-config --require tsx test/hashlock.test.ts
All four tests should pass without touching the network.
Contract Deployment & Call
Before deploying to the testnet you need a funded key. Export your testnet address with:
npm run print:testnet-address
Then fund it using the OP_CAT testnet faucet.
Replace the contents of scripts/deploy.ts with:
import { createHash } from 'node:crypto';
import {
createTestnetProvider,
createWifSigner,
getWifAddress,
} from '@opcat-labs/lambit';
import { Hashlock } from './contracts/hashlock';
// ── Guard ─────────────────────────────────────────────────────────────────────
const TESTNET_WIF = process.env.TESTNET_WIF;
if (!TESTNET_WIF) {
throw new Error('Set TESTNET_WIF before running this script.');
}
// ── Message and hash ──────────────────────────────────────────────────────────
const SECRET = 'hello world';
// The message as UTF-8 hex bytes — what the spender provides at call time.
const MESSAGE_HEX = Buffer.from(SECRET, 'utf8').toString('hex');
// The SHA-256 hash — what is embedded in the locking script at deploy time.
const HASHLOCK = createHash('sha256')
.update(Buffer.from(SECRET, 'utf8'))
.digest('hex');
// ── Provider and signer ───────────────────────────────────────────────────────
// createWifSigner wraps a WIF private key in the lambit Signer interface.
const signer = createWifSigner(TESTNET_WIF, 'testnet');
// createTestnetProvider connects to the OP_CAT testnet backend.
// It discovers wallet UTXOs, estimates fees, and adds change outputs automatically.
const provider = createTestnetProvider({
wif: TESTNET_WIF,
network: 'testnet',
});
console.log('Funding address:', getWifAddress(TESTNET_WIF, 'testnet'));
// ── Bind the contract ─────────────────────────────────────────────────────────
// Calling Hashlock(props) substitutes the concrete hash into the locking script
// template and returns a bound contract ready for deployment.
const hashlock = Hashlock({ hashlock: HASHLOCK });
console.log('Locking script:', hashlock.artifact.hex);
// ── Deploy ────────────────────────────────────────────────────────────────────
console.log('\nDeploying Hashlock contract...');
// deploy() broadcasts a funding transaction that locks the specified satoshis
// in a UTXO governed by the Hashlock locking script.
const deployed = await hashlock.deploy({
provider,
satoshis: 2_000n,
});
console.log(`Hashlock contract deployed: ${deployed.utxo.txid}`);
// ── Call ──────────────────────────────────────────────────────────────────────
console.log('\nCalling unlock...');
// call() builds the spending transaction, verifies the script locally before
// broadcast, then submits to the testnet.
//
// Hashlock requires only the secret message — no signature check — so there is
// no `signer` or `invoke` here. A contract with a signature requirement would
// also pass:
// signer,
// invoke: (psbt) => ({ sig: psbt.getSig(0, { publicKey }) })
const spendResult = await deployed.methods.unlock.call(
{ message: MESSAGE_HEX },
{ provider },
);
console.log(`Hashlock contract \`unlock\` called: ${spendResult.txid}`);
Run the deployment script:
TESTNET_WIF=<your_wif> npx tsx scripts/deploy.ts
You will see output like:
Funding address: <your testnet address>
Locking script: a820<32 hash bytes>8769
Deploying Hashlock contract...
Hashlock contract deployed: 8eb8cfcd18e006f92addae5dd710...
Calling unlock...
Hashlock contract unlock called: dc000689cc7ab55046c0771e0789...
Congratulations — you have deployed and called your first Bitcoin covenant contract on the OP_CAT testnet.
lambit test --testnet is guarded. Set BITCOIN_FP_RUN_TESTNET_E2E=1 and
a funded TESTNET_WIF only when you intend to spend testnet coins.