Sighash Developer Guide
In OP_CAT smart contracts, there are two distinct types of signatures to understand:
- Context SigHash Preimage — lets the contract read the current transaction's context (inputs, outputs, state, etc.)
- User Signature — the user authorizes an NFT/Token transfer with their own private key
Sighash Type
The sighash type determines which parts of the transaction a signature commits to:
| Value | Type | Coverage |
|---|---|---|
0x01 | SIGHASH_ALL | all inputs + all outputs (default, most common) |
0x02 | SIGHASH_NONE | all inputs, no commitment to outputs |
0x03 | SIGHASH_SINGLE | all inputs + the output at the same index |
0x81 | SIGHASH_ANYONECANPAY \| ALL | only the current input + all outputs |
0x82 | SIGHASH_ANYONECANPAY \| NONE | only the current input, no commitment to outputs |
0x83 | SIGHASH_ANYONECANPAY \| SINGLE | only the current input + the output at the same index |
Lambit has no per-method sighash decorator; the sighash type is chosen per spend instead:
- Off-chain, at signing time. The runtime signs method arguments through
psbt.getSig(inputIndex, { address | publicKey, sighashTypes }), which defaults toSIGHASH_ALL(DEFAULT_SIGHASH_TYPE). PasssighashTypesto sign with a different type. - On-chain, at compile time. Native stateful spends commit the preimage with
the sighash type selected for the path:
SIGHASH_SINGLE(0x03) for successor-state outputs and single-output terminal paths,SIGHASH_ALL(0x01) for selected multi-output terminal paths. The compiler asserts the committed preimage'ssigHashTypeso the selected type cannot be substituted. - In the contract. A method reads back the committed type with
ctx.sigHashType(a 4-byte little-endian value) and can constrain it.
The sighash type has two effects:
Determines the scope of the Context SigHash Preimage — the sighash type selected for the spend controls which aggregate hash fields in
ctxcover all inputs/outputs (see the field table below).Determines the preimage verified by
checkSig—checkSigverifies a User Signature against the transaction's sighash for the type embedded in the signature. The user must therefore sign off-chain with a matching sighash type (psbt.getSig'ssighashTypes) forcheckSigto pass.
Context SigHash Preimage
What it provides
When a public method executes, the runtime injects the current transaction's
context into the method. Declare one more callback argument than the schema
fields, and that final argument becomes a TxContextExpr proxy:
import { TypeTag, and, contract, eq, lit, method } from "@opcat-labs/lambit";
const MyContract = contract(
"MyContract",
{ expectedScriptHash: TypeTag.Sha256 },
({ props }) => ({
unlock: method({}, (ctx) => {
// read the current input index
const myInputIndex = ctx.inputIndex;
// verify the current input spends a specific locking script
const scriptMatches = eq(ctx.spentScriptHash, props.expectedScriptHash);
// whitelist the sighash type used for this spend (0x01 = SIGHASH_ALL)
const sigHashAll = eq(ctx.sigHashType, lit("01000000"));
return and(and(scriptMatches, sigHashAll), eq(myInputIndex, lit(0n)));
}),
}),
);
The compiler appends only the ctx fields the method actually reads, so unused
fields never appear in the ABI or the deployed script. Use
method.named(TxContext, (ctx) => ...) when you intentionally want the full
context object in the ABI.
How to use it
The runtime handles everything automatically — you do not need to manually pass
in or verify the preimage. Simply use the trailing ctx parameter directly
inside contract methods:
// constrain transaction outputs (prevent tampering)
// - stateful methods: return { next, check } or { outputs, check } and the
// compiler commits the output vector
// - stateless methods: use the assertOutputs([...]) helper
const outputMatches = assertOutputs([...]);
// restrict which sighash type the caller may use
const typeAllowed = or(
eq(ctx.sigHashType, lit('01000000')), // SIGHASH_ALL
eq(ctx.sigHashType, lit('81000000')), // SIGHASH_ANYONECANPAY | ALL
);
Complete ctx fields
ctx exposes the canonical TxContext preimage surface. Byte-oriented fields
such as nVersion, outpoint, and nSequence stay exposed as raw bytes so
contracts can inspect the exact serialized preimage form; inputIndex and
nLockTime are decoded as Int for bigint-native comparisons:
ctx.nVersion; // 4 bytes — transaction version (raw bytes)
ctx.hashPrevouts; // 32 bytes — aggregate hash of all input prevouts
ctx.inputIndex; // Int — current input index
ctx.outpoint; // 36 bytes — prevout of the current input (txHash + outputIndex)
ctx.spentScriptHash; // 32 bytes — script hash of the UTXO spent by the current input
ctx.spentDataHash; // 32 bytes — state hash of the current input
ctx.value; // Int — satoshis of the current input
ctx.nSequence; // 4 bytes — sequence of the current input (raw bytes)
ctx.hashSpentAmounts; // 32 bytes — aggregate hash of all input amounts
ctx.hashSpentScriptHashes; // 32 bytes — aggregate hash of all input script hashes
ctx.hashSpentDataHashes; // 32 bytes — aggregate hash of all input state hashes
ctx.hashSequences; // 32 bytes — aggregate hash of all input sequences
ctx.hashOutputs; // 32 bytes — aggregate hash of all outputs
ctx.nLockTime; // Int — transaction lock time
ctx.sigHashType; // 4 bytes — sighash type flag (little-endian)
The coverage of aggregate hash fields varies by sighash type:
| Field | ALL | NONE | SINGLE | ANYONECANPAY_* |
|---|---|---|---|---|
hashPrevouts | all inputs | all inputs | all inputs | current input only |
hashSpentAmounts | all inputs | all inputs | all inputs | current input only |
hashSpentScriptHashes | all inputs | all inputs | all inputs | current input only |
hashSpentDataHashes | all inputs | all inputs | all inputs | current input only |
hashOutputs | all outputs | empty | output at same index | depends on base type |
User Signature
When you need it
When a contract must be callable only by a specific user, verify a User Signature. The user signs the current transaction with their private key; the contract checks that the signature is valid and the corresponding public key matches the expected identity, ensuring only that user can execute the operation.
Common scenarios: NFT/Token ownership transfers, Guard deployer authorization, contract operations requiring an admin signature, etc.
Verifying inside a contract
import {
TypeTag,
and,
checkSig,
cond,
contract,
eq,
hash160,
len,
lit,
method,
} from "@opcat-labs/lambit";
const OwnerGate = contract(
"OwnerGate",
{},
{ owner: TypeTag.ByteString },
({ state }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey, ctx) =>
cond(
// contract-controlled: owner stores a 32-byte script hash
eq(len(state.owner), lit(32n)),
// verify the current input spends the owner contract
eq(state.owner, ctx.spentScriptHash),
// user-controlled: verify the pubkey identity and the signature
and(eq(hash160(pubkey), state.owner), checkSig(sig, pubkey)),
),
),
}),
);
Available signature verification methods
checkSig — standard ECDSA signature verification. The message is the
transaction's sighash for the type embedded in the signature, so the signer must
use a sighash type consistent with the spend:
checkSig(sig: ExprNode, pubKey: ExprNode): ExprNode
// example: the owner signs with the default SIGHASH_ALL
const OwnerVault = contract(
'OwnerVault',
{ ownerPkh: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) =>
and(eq(hash160(pubkey), props.ownerPkh), checkSig(sig, pubkey)),
),
}),
);
Accepting multiple sighash types
If the contract needs to accept signatures with multiple sighash types (e.g. both
SIGHASH_ALL and ANYONECANPAY | ALL), read the committed type from ctx.sigHashType,
assert it is in the whitelist, and sign off-chain with the matching sighashTypes:
const MultiSighash = contract(
"MultiSighash",
{ ownerPkh: TypeTag.Ripemd160 },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey, ctx) => {
// 4-byte little-endian: SIGHASH_ALL = 0x01, ANYONECANPAY | ALL = 0x81
const sigHashAll = eq(ctx.sigHashType, lit("01000000"));
const sigHashAcpAll = eq(ctx.sigHashType, lit("81000000"));
// whitelist: only allow SIGHASH_ALL or ANYONECANPAY | ALL
const typeAllowed = or(sigHashAll, sigHashAcpAll);
return and(
and(eq(hash160(pubkey), props.ownerPkh), typeAllowed),
checkSig(sig, pubkey),
);
},
),
}),
);
When signing off-chain, specify the sighash type explicitly via the sighashTypes
option of getSig:
const spend = await deployed.methods.unlock.call(
{ pubkey },
{
provider,
signer,
invoke: (psbt) => ({
// explicitly sign with ANYONECANPAY | ALL
sig: psbt.getSig(0, { address: ownerAddress, sighashTypes: [0x81] }),
}),
},
);
Signing off-chain
Use psbt.getSig() in the invoke callback to obtain a User Signature. It
defaults to SIGHASH_ALL:
const spend = await deployed.methods.unlock.call(
{ pubkey },
{
provider,
signer,
invoke: (psbt) => ({
// defaults to SIGHASH_ALL
sig: psbt.getSig(0, { address: ownerAddress }),
}),
},
);
Notes
- If a non-empty signature fails verification, the script terminates immediately (it does not return false). Ensure signatures are correct when building the transaction; otherwise nodes will reject the entire transaction.
- The format (compressed/uncompressed) of the
pubkeypassed in must be consistent with how the owner hash (ownerPkh/state.owner) was generated, becausehash160(pubkey)is compared against it.
Examples
NFT — owner-gated transfer
The current owner authorizes a transfer to a new owner with a User Signature, and
the successor state records the new owner. checkSig binds the signature to this
transaction, so it cannot be replayed on a different transfer:
import {
TypeTag,
and,
checkSig,
contract,
eq,
hash160,
method,
neq,
} from "@opcat-labs/lambit";
const Cat721 = contract(
"Cat721",
{},
{ ownerPkh: TypeTag.Ripemd160, localId: TypeTag.Int },
({ state }) => ({
transfer: method(
{
sig: TypeTag.Sig,
pubkey: TypeTag.PubKey,
newOwnerPkh: TypeTag.Ripemd160,
},
(sig, pubkey, newOwnerPkh) => ({
next: {
ownerPkh: newOwnerPkh,
localId: state.localId,
},
check: and(
// the signer must be the recorded owner
eq(hash160(pubkey), state.ownerPkh),
// ...and the owner must authorize this transfer
and(checkSig(sig, pubkey), neq(newOwnerPkh, state.ownerPkh)),
),
}),
),
}),
);
Off-chain call:
const spend = await deployed.methods.transfer.call(
{ pubkey: currentOwnerPubKey, newOwnerPkh },
{
provider,
signer,
invoke: (psbt) => ({
// the owner signs the current transaction; defaults to SIGHASH_ALL
sig: psbt.getSig(0, { publicKey: currentOwnerPubKey }),
}),
nextState: { ownerPkh: newOwnerPkh, localId },
},
);
Vault — owner signature + locktime
A stateless vault that releases funds only when an absolute locktime has matured
and the owner has signed. This shows combining a User Signature with context
guards such as checkLocktime:
import {
TypeTag,
and,
checkLocktime,
checkSig,
contract,
eq,
hash160,
method,
} from "@opcat-labs/lambit";
const TimedVault = contract(
"TimedVault",
{ owner: TypeTag.Ripemd160, releaseHeight: TypeTag.Int },
({ props }) => ({
unlock: method(
{ sig: TypeTag.Sig, pubkey: TypeTag.PubKey },
(sig, pubkey) =>
and(
checkLocktime(props.releaseHeight),
and(eq(hash160(pubkey), props.owner), checkSig(sig, pubkey)),
),
),
}),
);
API Reference
| API | Location | Description |
|---|---|---|
trailing ctx parameter | dsl/method.ts | Current transaction context for a method; only the fields read are compiled in |
method.named(TxContext, (ctx) => ...) | dsl/method.ts | Full canonical context object in the ABI |
checkSig(sig, pubKey) | dsl/primitives.ts | Verify a user signature against the transaction's sighash |
ctx.sigHashType | dsl/contextTypes.ts | Sighash type used by the spend (4-byte little-endian) |
psbt.getSig(inputIndex, { address \| publicKey, sighashTypes }) | runtime/psbt.ts | Off-chain signing request resolved by the signer |
DEFAULT_SIGHASH_TYPE | runtime/bvm.ts | Runtime default sighash type (SIGHASH_ALL) |
hash160(pubkey) / pubKey2Addr(pubkey) | dsl/primitives.ts | Derive the 20-byte owner hash from a public key |