It’s well-known that Bitcoin has a more constrained scripting system than other blockchains like Ethereum or Solana, which more directly support running smart contracts. Despite this, many people want build systems that interoperate with Bitcoin, in part due to it being the oldest, longest lasting blockchain. To overcome Bitcoin’s constraints, people combine its scripting capabilities with properties of its supported signature schemes in order to enforce higher-level properties with a mix of on-chain and off-chain logic.
In this post, we’ll take a glance at how Bitcoin’s scripting system works — including its supported signature schemes and the system’s evolution — as well as how several projects make use of them to build functionality not directly expressible in Bitcoin.
Overview of Selected Features of Bitcoin
Let’s first take a look at some of the key elements of Bitcoin’s system, including BIP-340 Schnorr signatures, Bitcoin’s Script VM, common script types like pay-to-pubkey hash and pay-to-script hash, extensions to the script system like SegWit version 0, Taproot scripts, covenants, and an extension to the signature scheme, FROST signatures.
BIP-340 Schnorr Signatures
Bitcoin uses digital signatures to authorize transactions (or more strictly, makes verification of signatures available as a primitive condition that can be used as part of a script that authorizes transactions).
Prior to the Taproot upgrade, Bitcoin exclusively used Elliptic Curve Digital Signature Algorithm (ECDSA) signatures.
Since Taproot, it additionally uses Schnorr signatures in some contexts.
Both signature schemes use the Secp256k1 elliptic curve, whose elements
are 256 bits (32 bytes); both use a uniformly random (over the group
order) scalar d
as a private key and multiply it by a fixed
generator G
to generate a public key P
; and both require a
unique, uniformly random, secret nonce value k
per message m
signed, but their implementations of signing and verification differ.
In the below pseudocode, n
denotes the group order and H
denotes
a hash function, and type conversions between byte strings, integers
modulo n
, and group elements are elided, as are various
well-formedness and parity checks; for full details, see Bitcoin’s ECDSA implementation↗
and Bitcoin’s Schnorr implementation↗.
Here are the ECDSA signatures:
def sign_ecdsa(d, m, k):
e = H(m)
R = k*G
r = R.x
s = pow(k, -1, n)*(e + r * d)
return (r, s)
def verify_ecdsa(P, m, sig):
(r, s) = sig
e = H(m)
u1 = e*pow(s, -1, n)
u2 = r*pow(s, -1, n)
R = u1 * G + u2 * P
return R.x == r
Here are the Schnorr signatures:
def sign_schnorr(d, m, k):
P = d * G
R = k * G
e = H(R.x || P.x || m)
s = k + e * d
return (R.x, s)
def verify_schnorr(P, m, sig):
(r, s) = sig
e = H(r || P.x || m)
R = s * G - e * P
return R.x == r
The idea of both is that the R
point is a commitment to k
that’s binding (since R
is uniquely determined by k
, since
G
is fixed) and hiding (due to the discrete logarithm hardness
assumption). The message is committed to by being hashed to e
and
then turned into s
with an equation over the scalar field of the
elliptic curve such that it can be checked that e
is a unique
solution to an equation determined by k
and d
with only the
commitments R
and P
, without knowing k
and d
(the
discrete logarithms of R
and P
with respect to G
, which only
the signer knows). With Schnorr, this equation is linear; with ECDSA, it
is not.
Both ECDSA and Schnorr signatures allow recovering the private key d
given a pair of signatures (sig1, sig2)
on distinct messages (m1, m2)
that use the same nonce k
:
def recover_ecdsa(sig1, sig2, m1, m2):
r1, s1 = sig1
r2, s2 = sig2
assert r1 == r2 # since k was reused
e1, e2 = H(m1), H(m2)
k = (e2 - e1) * pow(s2 - s1, -1, n)
d = (s1 * k - e1) * pow(r, -1, n)
return d
def recover_schnorr(P, sig1, sig2, m1, m2):
r1, s1 = sig1
r2, s2 = sig2
assert r1 == r2 # since k was reused
e1, e2 = H(r1 || P.x || m1), H(r2 || P.x || m2)
d = pow(e2 - e1, -1, n) * (s2 - s1)
return d
Additionally, if ECDSA and Schnorr signatures are generated for the same key with the same nonce, that also allows recovering the private key, regardless of whether or not the messages are distinct:
def recover_mixed(P, sig_ecdsa, sig_schnorr, m_ecdsa, m_schnorr):
r_ecdsa, s_ecdsa = sig_ecdsa
r_schnorr, s_schnorr = sig_schnorr
assert r_ecdsa == r_schnorr # since k was reused
r = r_ecdsa
r_inv = pow(r, -1, n)
e_ecdsa, e_schnorr = H(m_ecdsa), H(r_schnorr || P.x || m_schnorr)
k = (s_schnorr + e_schnorr * e_ecdsa * r_inv) * pow(1 + e_schnorr * s_ecdsa * r_inv, -1, n)
d = (k * s_ecdsa - e_ecdsa) * r_inv
return d
Schnorr signatures permit signatures to be efficiently generated for an
aggregate key as if it was an individual key by summing partial signatures
produced with knowledge of the aggregate public key and the individual private
keys, without a single party holding the private keys that make up the
aggregate. If (d1, d2)
are private keys, then sig = sign_schnorr(d1+d2, m, k1+k2)
is a valid signature with public key (d1+d2)*G
according to
verify_schnorr((d1+d2)*G, m, sig)
.
This can be produced by parties holding d1
and d2
separately
producing the r
values independently as r1 = k1*G
, r2 = k2*G
, summing them to produce r
, summing their public keys to
produce the aggregate public key (d1+d2)*G
, computing a shared e = H(r || ((d1+d2)*G).x || m)
, generating individual s
values
s1 = k1 + e * d1
and s2 = k2 + e*d2
, and summing them to
produce the aggregate signature’s s = s1 + s2
. Threshold signature
schemes like FROST specify more details of this process, such as exactly
what values to send and when, how to handle more than two signers, and
how to detect signers submitting incorrect values, since if intermediate values
are exchanged naively without additional validation, one party can cause the
other parties’ keys to be revealed.
Protocols for producing aggregate ECDSA signatures require more rounds than those for producing aggregate Schnorr signatures, see A Survey of ECDSA Threshold Signing↗ for details.
Unlike BLS signatures, Schnorr signatures cannot be aggregated after they are
created with individual public keys, since if (r1, s1) = sign_schnorr(d1, m, k1)
and (r2, s2) = sign_schnorr(d2, m, k2)
, the intermediate values e1 = H(r1 || (d1*G).x || m)
and e2 = H(r2 || (d2*G).x || m)
used to compute s1
and s2
contain hashes of the individual public keys, which won’t match the
aggregated public key (d1+d2)*G
.
Script VM
Bitcoin scripts are programs for a stack-based virtual machine, consisting of opcodes that operate on data at and near the top of the stack. The stack elements are variable-sized strings of bytes, with a limit of 520 bytes per stack element. Stack-based programs can be composed by concatenating them, causing the second program to operate on stack data produced by the first program as its input.
Each Bitcoin transaction output contains a script called its
scriptPubKey
, which specifies the conditions under which the output
can be spent.
A subsequent transaction input, in order to spend a previous
transaction’s output, provides a script called its scriptSig
that
provides an input stack for the output’s scriptPubKey
. For an
input-output pair to be valid, the execution of the concatenated
scriptSig
and scriptPubKey
must halt without errors and with a
nonzero stack element at the top of the stack.
In summary:
scriptPubKey
is part of the output, and is a string of VM opcodes that checks whether the output’s spend conditions are satisfied.scriptSig
is part of the input, and is what “unlocks” thescriptPubKey
. It is also a string of VM opcodes, and it is prepended toscriptPubKey
, and the combined bytecode is executed in the VM.
Pay-to-Pubkey Hash
The most common scriptPubKey
script, called pay-to-pubkey hash
(P2PKH), allows the output to be spent by a specific key pair by
requiring the entire transaction to be signed by that key pair.
It consists of OP_DUP OP_HASH160 <hash160(pubkey)> OP_EQUALVERIFY OP_CHECKSIG
, which enforces that the corresponding scriptSig
is of
the form <sig> <pubkey>
, where <data>
denotes an
instruction to push literal data.
In the combined script, <pubkey> OP_DUP OP_HASH160 <hash160(pubkey)> OP_EQUALVERIFY
enforces that the top element of
the stack is the specified hash, leaving it on the stack (due to the
OP_DUP
), and aborting (causing the transaction to be invalid) if the
hash doesn’t match or if the stack has too few elements.
After this, the remaining execution state <sig> <pubkey> OP_CHECKSIG
verifies that sig
is an ECDSA signature of the entire
transaction (with the exception of the script — to avoid circularity
that would require finding a fixed point of the hash function) signed by
pubkey
.
Pay-to-Script Hash
The Bitcoin Improvement Proposal BIP-16↗ introduced the capability for a transaction output to specify its spending condition as a fixed-sized hash as a commitment to the script, instead of including the variable-sized script directly, deferring the cost of storing the script until the transaction that spends it.
It does this by performing additional validation for transactions
spending an output whose scriptPubKey
matches the form OP_HASH160 <data-of-length-20> OP_EQUAL
, called a pay-to-script hash (P2SH)
transaction. Prior to BIP-16, Bitcoin implementations would interpret
this scriptPubKey
as is, only requiring the top element of the stack
after the execution of the combined input scriptSig
and output
scriptPubKey
to have the specified hash.
Implementations that incorporate BIP-16 add additional validation requirements.
They treat the value that is hashed (innerScriptPubKey
in the diagram) in the output’s scriptPubKey
as a serialized script.
This serialized script functions as a new scriptPubKey
.
During validation, the outer execution pushes the inner scriptSig
onto the stack.
Then, this inner scriptSig
is executed against the new scriptPubKey
in an additional execution of the script VM.
This second script execution must also succeed for the transaction to be considered valid.
In essence, the outer execution verifies that the provided script matches the hash committed in the output, while the inner execution actually runs the spending conditions encoded in that script—the “script” part of “pay to script hash.”
SegWit Version 0
Prior to
BIP-141↗,
SegWit scripts were included directly in transactions within blocks,
requiring them to be replicated to any entity that processes blocks at
all, even ones that don’t verify script executions. SegWit allows
transactions to opt in to storing their scripts in a separate witness
tree whose root hash is committed to as part of the main block and
whose bytes are considered to be one third as expensive as bytes in the
main block for the purposes of fee calculations. SegWit transaction outputs use
a small scriptPubKey
consisting of metadata indicating what data to expect as
part of the witness (this metadata would fail to be a valid transaction prior
to the adoption of BIP-141, ensuring that only nodes that adopt BIP-141
consider spends of SegWit transaction outputs valid). The metadata includes a
version byte, for which BIP-141 only defines spending rules for version 0,
leaving other versions to be interpreted in a forward-compatible way by future
proposals. The scriptWitness
field of a transaction input that spends a
SegWit output is not included in regular blocks, but is included in witness
blocks. A witness block can be verified against a regular block by hashing the
witnesses of transaction inputs and checking that it matches the root hash
committed to in the corresponding regular block.
SegWit V0 scriptPubKey
s must be either a pay-to-witness pubkey hash (P2WPH) or
pay-to-witness script hash (P2WSH). A P2WPH script is a 20-byte
HASH160
hash of a public key, and it requires that the witness
script consists of a signature and a public key, which are verified as
if by a P2PKH script. A P2WPH script is three bytes shorter than a P2PKH
script, due to having the SegWit version byte instead of the four
opcodes in the P2PKH script. A P2WSH script is a 32-byte SHA-256 hash of
a script to be deserialized from the last element of the witness script,
verified against the script hash, and then concatenated with the
remainder of the witness script. This allows arbitrary scripts to be
used with SegWit, taking up a constant amount of block space in the size
of the script (instead of linear). P2WSH shares the advantage of P2SH of
not revealing the script until the output is spent, and it additionally
saves on costs by moving the reveal of the script from the block to the
witness.
Taproot scripts
BIP-341↗’s
Taproot defines version 1 SegWit transaction outputs to contain a point
Q = P + H(P || m)*G
and to be redeemable by either providing a
Schnorr signature of the transaction data with Q
as a public key or
by providing P
, a script s
, and a proof that s
is included
in the Merkle root m
. (The inclusion proof is used to compute m
,
which is then used to recompute Q
and check that it matches the
Q
in the SegWit metadata.) Similarly to P2WSH, scripts are not
revealed until they are present as a witness input in a transaction that
spends them.
If the signer knows the private key corresponding to the internal key
P
(i.e., a scalar d
such that P = d*G
, they also know the
private key corresponding to the output key Q
, since Q = P + H(P || m)*G = d*G + H(P || m)*G = (d + H(P || m)*G
.
Several special cases of Taproot are significant:
-
If
m
is a commitment to an empty Merkle tree, this provides a Schnorr variant of pay to pubkey, which allows for cheaper multi-sig transactions through key aggregation. This usage is similar to P2PKH. -
If
P
is constructed to have no known discrete log with respect toG
, this provides a variant of P2SH that pays to a disjunction of scripts, but only the script that is executed costs witness space. -
The
P
key can be constructed to have no known discrete log in a deterministic way,P = H = lift_x(sha256(\"\\x04\"||G.x||G.y))
(wherelift_x
produces the unique point on the curve with the specified x-coordinate and an even y-coordinate), which allows anyone to verify that a Taproot output is a commitment to a specific set of scripts, if they know which scripts. -
The
P
key can be constructed to have no known discrete log in a nondeterministic way,P = H + rG
with a randomr
, which makes the Taproot output indistinguishable from one with an arbitrary pubkey but permits a proof that that the output’s creator does not know a discrete log forP
by revealingr
to the verifier (or by providing a zero-knowledge proof thatr
exists such thatP
was computed correctly).
Additionally, state machines with edge conditions verifiable in Bitcoin script can be represented using Taproot by making use of transactions that both consume and produce Taproot outputs (though covenants are additionally required to constrain the output). BitVM and Babylon both encode state machines this way, the former encoding a state machine for performing a fraud proof of execution of a circuit and the latter encoding a state machine for bonding and unbonding Bitcoin as stake.
Covenants and Covenant Emulation Committees
A transaction’s output’s script cannot currently depend on arbitrary fields of the transaction spending the output. If a script could depend on the outputs of the transaction spending it, it could require that output’s script to have a particular value. Making chains of these constraints to a fixed depth allows encoding state machines: an output A could require one of the outputs of the transaction spending it to be a script B with some minimum value, which can in turn require an output with script C in the next transaction.
General covenants, with the OP_CAT
opcode (which concatenates two stack
elements, are proposed to be re-enabled in
BIP-347↗)
and the OP_CHECKSIGFROMSTACK
opcode (which would check a Schnorr signature of
arbitrary data, specified in
BIP-348↗).
They would allow scripts to depend on any field that was included in the
transaction’s hash, by including the fields of the transaction separately
(duplicating them as needed to perform additional checks), concatenating them
with OP_CAT
, calculating the transaction hash and checking that it has a
valid signature with OP_CHECKSIGFROMSTACK
, and checking that the
signature is also valid for the transaction itself with OP_CHECKSIG
, which
ensures that the fields provided in the scriptSig
are actually the fields
that the transaction consists of.
Template covenants, with the OP_CHECKTEMPLATEVERIFY
opcode (specified in
BIP-119↗)
allow depending on a specific subset of the transaction’s fields, and they are
intended to make it infeasible to create self-propagating covenants (where a
script A requires that a copy of A be present in the next output’s script),
which are possible with covenants that make use of both OP_CAT
and
OP_CHECKSIGFROMSTACK
.
Since none of these opcodes are currently available in Bitcoin script, protocols that would otherwise make use of covenants instead use a Covenant Emulation Committee, which signs transactions that satisfy the constraints the covenant would have enforced. Wherever the transactions’ scripts would enforce the covenant, they instead check that at least some threshold of that protocol’s Covenant Emulation Committee has signed the transaction. For sets of transactions that enforce a state machine, the Covenant Emulation Committee pre-signs the entire set of transactions at once. Since the transactions require additional signatures to be submitted to Bitcoin (e.g., require a signature from the user submitting the initial transaction to provide funds or require signatures from other parties based on which actions they want to take) the Covenant Emulation Committee is not in a position to submit the transactions it’s cosigning early, and the user can choose to avoid entering the state machine if the committee only signs a subset of them.
FROST Signatures
Flexible round-optimized Schnorr threshold
signatures↗ (FROST Signatures for
short) is a protocol for distributed generation of a standard Schnorr
public key by n
participants such that any t
of them can produce
signatures for that message (i.e., t
-of-n
threshold signatures).
Unlike direct use of Shamir shares of the key, the corresponding private
key isn’t explicitly reconstructed during signing operations.
FROST-distributed key generation takes two rounds and produces a
long-term key that can be used to sign messages with the same set of
signers. In it, each participant generates random degree t - 1
polynomials, commits to the coefficients with a discrete-log commitment
scheme, produces proofs of knowledge of the constant coefficient (by
producing a Schnorr signature of a message containing the participant
index and the step in the protocol using the constant coefficient as a
signing key), broadcasts these commitments and proofs (so that each
participant can verify that each other participant is using their polynomial
consistently across messages), and sends per-participant shares of their
polynomial encrypted to each other participant. Each participant, as a
recipient, verifies each sender’s proof of knowledge and shares against the
sender’s commitments, and if there are no discrepancies, interpolates all the
senders’ shares to generate that recipient’s share of the signing key. If a
sender deviated from the protocol by sending shares inconsistent with their
commitments, this can be detected and key generation can be restarted with that
sender excluded.
FROST signing can either take two rounds (if one message is signed at a time) or one round (if a batch of nonces is precomputed). Nonces are generated using additive shares (with commitments to prevent an individual malicious participant from canceling out the sum of the randomness of the shares seen so far) and then converted to Shamir shares, which are used together with the long-term signing shares to compute signatures.
The non–batch-signing protocol, in addition to being described by the aforementioned paper, is additionally specified in IETF RFC-9591↗.
The frost_secp256k1_tr↗
crate is an implementation of FROST that produces signatures compatible
with BIP-340, and it is used by
zkBitcoin↗
and
Nomic↗.
Penumbra has the
decaf377-frost↗
implementation, which shares the frost-core
implementation with
frost-secp256k1-tr
but uses the decaf377 elliptic curve instead of
Secp256k1.
Previewing the Projects
Now we’ll take a look at how a few projects use these elements in their own software.
BitVM
BitVM↗ makes use of Taproot scripts to allow two parties (a prover and a verifier) to commit to a circuit, creating a UTXO that is spendable by a party determined by the output of the circuit if the prover provides inputs to evaluate the circuit or that can be spent by the verifier if the prover evaluates the circuit incorrectly or fails to provide inputs. Simply directly executing the circuit in Bitcoin script would take linear script size in the number of gates of the circuit, which would be impractical for larger circuits. BitVM’s best case is a single transaction, with a 2-of-2 multi-sig on the success path where the prover and verifier agree about the execution of the circuit off chain. The fallback is an interactive fraud proof that takes time proportional to the depth of the circuit (which is typically logarithmic in the number of gates of the circuit), where whichever party was incorrect about the circuit execution forfeits their deposit to the correct party.
For the proof that this is universal, it suffices to compile boolean circuits to only bit commitments and NAND gates, but for efficiency, this can also be done with arithmetic circuits.
For each bit in the circuit, the prover creates preimages (similar to
wire labels in garbled circuits) w_0
and w_1
, with a
corresponding bit-commitment script fragment OP_IF OP_HASH_160 <H(w_0)> OP_EQUALVERIFY <0> OP_ELSE OP_HASH_160 <H(w_1)> OP_EQUALVERIFY <1> OP_ENDIF
. If w_i
is published, this script
can be satisfied with <w_i> <i>
, which results in the commitment
script succeeding, pushing value <i>
to the stack. Someone knowing
neither preimage cannot satisfy the bit-commitment script. The prover
can set inputs to the circuit off chain by sending the preimages to the
verifier.
Each NAND gate of the circuit can be encoded as a script that takes
preimages a_i
and b_j
for the inputs and preimage c_k
for
the output, uses the bit-commitment scripts for bits a
and b
to
push i
and j
onto the stack to compute i NAND j
, uses the
bit-commitment script for c
to push k
onto the stack, and
verifies that k = i NAND j
. For each gate in the circuit, and for
each round of the fraud proof protocol, the verifier constructs a
hashlock of a request for the prover to evaluate that gate by choosing a
preimage whose hash is required before executing the Taproot leaf
corresponding to the execution of that gate.
For the fraud proof, starting with the verifier, the prover and verifier take alternate turns submitting transactions that progress the BitVM state machine (which they pre-sign as if they were a covenant emulation committee, in the absence of covenants). Edges that require it to be someone’s turn have a check for that party’s signature in that edge’s leaf script. The internal Taproot key is the aggregate of keys held by the prover and verifier, which allows the 2-of-2 multi-sig to be used for a fast success path. Each state also has a timelock path spendable by the counterparty to prevent the party whose current turn it is from delaying indefinitely to prevent a loss.
To ensure that the prover does not evaluate the circuit inconsistently
(e.g., using c_0
for one equation as an output and then c_1
for
a different equation as an input or committing to different inputs on
chain and off chain), on verifier turns, the verifier immediately claims
the deposit if they have both preimages for the same gate (accomplished
by, for each gate, adding a Taproot leaf that requires both preimages
for that gate).
On the verifier’s turn, they can choose which gate for the prover to evaluate next by publishing the preimage for the corresponding hashlock. The prover then must provide the preimages for the inputs and output of the chosen gate (by executing the leaf corresponding to that hashlock and gate equation).
Since, by invoking the fraud proof, an honest verifier disagrees with the prover about the circuit output, the first gate evaluation they request will be for the overall output gate of the circuit. By providing inputs to the final gate of the circuit, the prover reveals which subtree their input disagrees with the verifier’s input on (since the verifier can compute forward from their inputs off chain). Working backwards, the verifier can reach inputs to the circuit in as many rounds as the depth of the circuit, at which point they are in possession of an input preimage they were given off chain that does not match the prover’s on-chain input preimage, allowing them to claim the deposit due to knowing both preimages for that input.
If the verifier is not able to prove an inconsistency in the prover’s chosen inputs after the specified number of rounds, the prover evaluated the circuit consistently with the inputs they gave the verifier and claims the deposit.
Note that unlike garbled circuits or zero-knowledge proofs, neither party’s inputs are private. The prover’s inputs are revealed directly both on chain and to the verifier, and if the verifier were to obtain their input commitments in a manner similar to garbled circuits off chain, by using oblivious transfer with the prover to receive the commitments corresponding to their input bits without revealing those bits to the prover, the verifier would be able to issue spurious fraud proofs, as the prover would not know which way to evaluate the circuit for the verifier’s inputs. However, the circuit itself can be a verifier for an inner zero-knowledge proof algorithm, which is done by BitVM Bridge↗.
BitVM2
BitVM2↗ provides a similar primitive to BitVM, improving its flexibility and efficiency. Its program representation differs from BitVM’s in that the prover commits to a Bitcoin script of arbitrary length (which is split into chunks that individually do not exceed the maximum supported size of a natively executed Bitcoin script) instead of a boolean circuit. This representation allows more efficient fraud proofs, taking a constant number of rounds independently of the number of chunks (in contrast to BitVM’s logarithmic number of rounds in the number of gates of the circuit). BitVM2’s fraud proofs are more flexible than BitVM’s by allowing any Bitcoin user to participate as the verifier role if the prover claims an execution of the program inconsistent with their input, rather than requiring setup for each prover-verifier pair.
For each instance of the protocol, the prover creates a Lamport keypair for
each chunk of the program. The Lamport keypair generalizes the bit commitments
used in BitVM to fixed-length bitstrings. Unlike in BitVM, the Lamport
signatures aren’t directly used for fraud proofs by revealing part of the key
if multiple messages were signed (note that for 1-bit Lamport signatures, the
keys are equal to the signatures); they’re used instead as a signature scheme
that’s verifiable on data provided within a Bitcoin script (OP_CHECKSIG
only
supports checking signatures on the transaction, and
OP_CHECKSIGFROMSTACK
↗,
which would support this, isn’t part of Bitcoin yet). The prover uses these to
sign the stack that results from executing each chunk so that if their claimed
output for the entire program is incorrect, a verifier can execute the program
chunk-by-chunk off chain to find the first chunk whose execution mismatches the
input, at which point they have signatures on an input and output pair for a
chunk that can be verified to be inconsistent with the execution on chain.
The BitVM2 protocol makes use of a Covenant Emulation Committee, a committee of signers that enforce that the prover’s transactions match what the protocol requires by signing transactions that have the required structure with n-of-n multi-signatures.
To commit to a program to be executed, the prover creates a set of six linked transactions that enable the prover to later evaluate the program on an input then either receive their deposit after a timelock if their output is correct for that input or, for any verifier that notices that the claimed output is incorrect, submit a fraud proof and receive the deposit instead.
These transactions are as follows:
- The
Claim
transaction takes a funding input (which can allow someone other than the prover to fund the use of this protocol) and specifies the initial stack value that the program to be verified will execute on. Its first output is a Taproot output with a timelock leaf for thePayoutOptimistic
path and a leaf for theAssert
path, which checks Lamport signatures for the claimed intermediate states to commit to them on chain. Its second output is a zero-value connector output that is used as an input to thePayoutOptimistic
andChallenge
transactions to ensure that they are mutually exclusive. - The
PayoutOptimistic
transaction’s inputs are the timelock path of theClaim
transaction’s Taproot output as well as its connector output, and its output is spendable by the prover. This is used to avoid posting the commitments to the intermediate states on chain if no challenge is issued during the timelock period. - The
Challenge
transaction takes theClaim
transaction’s connector input as well as a funding input that a verifier pays as a fee. Its output is spendable by the prover. This fee discourages verifiers from issuing spurious challenges, and covers the prover’s transaction costs for publishing theAssert
transaction. - The
Assert
transaction executes theClaim
transaction’s output with theAssert
path, which commits to the intermediate stack values and the Lamport keys on chain. Its Taproot output contains a leaf for challenging each chunk of the program to be verified (each of which checks the provided input and output signatures, runs the chunk, and checks that the output does not match the signed output) as well as a success-path leaf that allows the prover to spend the output with their public key after a timelock. - The
Disprove
transaction for a chunk takes theAssert
transaction’s Taproot output as its input, executing the leaf that shows incorrect execution of that chunk. It has two outputs, one of which must render a specified amount of the deposit unspendable; the other allows the remainder to be spent by the verifier. - The
Payout
transaction takes theAssert
transaction’s Taproot output as its input and executes the timelock path, allowing the prover to spend the deposit.
BitVM Bridge makes use of BitVM2 to allow bridging BTC to other blockchains. It requires that the counterparty blockchain have a Bitcoin light client and that the counterparty blockchain posts its state to Bitcoin. Users send BTC to the counterparty blockchain by submitting a transaction to the Bitcoin network that the counterparty recognizes, minting bridged BTC on the counterparty. Users on the counterparty blockchain can send BTC back to Bitcoin by burning the bridged BTC and producing a Groth16 proof that the bridged BTC was burned, which is verified by BitVM2 instances on Bitcoin.
zkBitcoin
zkBitcoin↗ allows the creation of Bitcoin outputs whose spend conditions are given by zero-knowledge circuits, called zkApps.
Unlike BitVM, it doesn’t verify the proofs directly on chain; it has a committee of FROST participants that manage a threshold signature wallet, which verifies zero-knowledge proofs that are sent to it and signs the corresponding transactions.
The implementation uses PLONK as the proof system, which has universal setup, allowing the committee to use the same setup for multiple circuits. Creator of zkApps specify them as Circom programs, which zkBitcoin’s tooling deploys to the Bitcoin network.
Stateless zkApps are created by Bitcoin transactions with two outputs
that are recognized by the zkBitcoin committee: one sends the value to
be managed by the zkApp to the committee’s address, and the other
commits to a hash of a circuit’s verification key by including it as an
immediate argument of an OP_RETURN
(the latter output is unspendable).
The committee signs transactions that take a zkApp’s output as input
and include a fee to the committee if their submitter provides a
verification key that matches the zkApp’s verification-key hash and a
proof that verifies with that verification key and the transaction hash
as a public input. This allows the circuit to enforce additional
constraints on the structure of the transaction by deriving a
transaction hash within the circuit from private inputs and constraining
it equal to the public input.
Stateful zkApps are similar and additionally include an initial state with the verification key in the transaction that creates them. In addition to the transaction hash, the public inputs include an old and new state and a withdraw and deposit amount. This allows the circuit to enforce the computation of a new state from the old state and to enforce state-dependent conditions on the withdraw and deposit amounts. The committee enforces outside the circuit the withdraw and deposit amounts, the stateful zkApp’s balance, and the transaction’s inputs and outputs’ balance.
The zkBitcoin wallet is an ordinary wallet from Bitcoin’s perspective; the outputs it can spend are Taproot outputs with no script paths, to support the use of BIP-340 Schnorr signatures with FROST. As such, the security of zkBitcoin depends on the members of the committee not having their key material aggregated outside of the protocol, whether by collusion or hacks, to reconstruct a private key that can spend the zkBitcoin wallet’s funds with no constraints.
Babylon
Babylon↗ is a proof-of-stake chain secured by Bitcoin stake. Stakers delegate to finality providers, which sign blocks on the Babylon chain to provide consensus. A finality provider’s vote weight for consensus is proportional to the BTC delegated to them, and stakers are issued BBN proportionally to the amount that they stake. If a finality provider attempts a double-spend by signing two different blocks for the same height, their delegators are slashed: 10% of their stake is sent to an unspendable burn address, and the remaining 90% is returned to the staker. In the absence of malicious finality-provider behavior, stakers can unbond to receive their staked BTC in 101 Bitcoin blocks (approximately 17 hours), a delay period in which slashing can still occur.
Babylon uses Taproot scripts to ensure that the staked funds are only usable according to this state machine. A staking transaction contains a commitment to the staking output, a Taproot output consisting of three scripts, with a deterministic unspendable internal key allowing the staking transaction to be recognized by reconstructing it from a delegation’s data.
The staking output’s first script is the timelock path, which requires
the Staker’s signature and a
BIP-112↗
OP_CHECKSEQUENCEVERIFY
timelock based on the intended duration of
the delegation in the absence of unbonding. The second script is the
unbonding path, requiring the staker’s signature and a threshold of
Covenant Emulation Committee member signatures, which enforce
the structure of the unbonding transaction. The third script is the
slashing script, which requires the staker’s signature; a threshold of
Covenant Emulation Committee member signatures, which enforce the
structure of the slashing transaction; and the signature of the finality
provider being delegated to, whose key is revealed through the structure
of the signatures used for signing blocks if multiple blocks are signed
for the same height.
The unbonding transaction is presigned by the Covenant Emulation
Committee, consumes the staking output through the second path, and has
an unbonding output with two paths: a timelock path, which requires the
staker’s signature and the 101-block OP_CHECKSEQUENCEVERIFY
timelock, and a slashing path with the same requirements as the staking
output’s slashing path. The unbonding path of the staking script
requires the Covenant Emulation Committee member signatures in order to
ensure that it is only exercised with this delay period.
The slashing transactions (one each for the staking and unbonding
output) must be signed by the staker in order for their delegation to
become active. The Covenant Emulation Committee enforces that the
slashing transactions burn 10% of the delegation’s stake. The finality
providers sign proof-of-stake blocks with extractable one-time
signatures — a variant of Schnorr signatures that derive the nonce as
a function of the height for the block being signed — and the finality
provider’s key, submitting the R
value to the Babylon chain before
the s
value (similar to what discreet log contracts call committed
R-point signatures). This ensures that if they attempt to submit two
different s
values for the same height, sufficient information is
present to recover the finality provider’s key via recover_schnorr
,
which allows anyone observing a double-sign attempt to submit the
slashing transaction to the Bitcoin network. If a finality provider acts
honestly, signing at most one block per height, they’re using
unpredictable nonces, which does not reveal their private key.
Discreet Log Contracts
Discreet log contracts↗ provide a mechanism by which oracles can publish data external to the blockchain (e.g., price feeds) in a way that allows a pair of parties to pre-sign transactions, one of which can be spent based on the revealed price (e.g., to effect a foreign-currency swap based on the price feed), without the oracle having to receive any input from the parties (or even being able to detect that its output is being used by any particular transaction).
The oracle uses committed R-point signatures, a variant of Schnorr
signatures in which the nonce k
is sampled and R = kG
is
published before the message or s
value of the signature are known.
For price feeds, the oracle commits to a different R point per asset and
per time window. When a price-feed entry is to be published, the oracle
signs the price as the message with the R-point associated with its time
window.
To perform a swap based on a future price from a price feed, two parties
fund a multi-signature output and pre-sign one transaction for each
possible message that spends that output. The transactions have outputs
with message-dependent values, with one output sending directly to the
counterparty as a P2PKH output and the other with a spend condition
(P2SH in the paper, but P2WSH/Taproot would also work) that allows it to
be spent immediately by one party with a tweaked public key that
incorporates sG
for the corresponding message (which is computable
from the oracle’s public key, R, and the message) or after a timelock
by the counterparty.
The oracle’s signature s
for a message allows the party with public
key A = aG
to spend the P2SH output requiring pubkey A+sG
, since
they know the corresponding private key a+s
, allowing them to
immediately claim their own output from the swap from the P2SH output
and their counterparty to claim the P2PKH output. If a party publishes a
transaction for the wrong message, with public key A+s1G
(consuming
the multi-signature output), the timelock path allows the counterparty
to claim the P2SH output, since the first party won’t know s1
,
since the oracle will have published a distinct signature s2
.
If an oracle signs multiple messages for the same R
(e.g., multiple
prices for the same timeslot), they reveal their long-term private key
through nonce reuse. To handle the case where an oracle fails to provide
any output, the parties can pre-sign a transaction that refunds them both
from the multi-signature output with a timelock. To handle the case
where an oracle misreports a price without signing multiple messages, a
conjunction of oracles can be used, where a transaction is valid for a
price if all the oracles agree and the timelock refund path is used to
handle mismatches.
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.