Context/prerequisite: part 1 – based rollups design.
A primary objective of the based rollup design is to support the entry and exit of L1 KAS funds to and from the L2—a mechanism often referred to as a canonical bridge.
The end result should be a bridged KAS token (henceforth referred to as BKAS
), which can be used on L2 and has a 1:1 relation with native L1 KAS. From the perspective of L1, this means there will be a “pool” of native KAS allocated across several UTXOs, which is locked and owned by the L2. The internal distribution of this pool is managed by the L2 state. A user should be able to initiate an “entry” by sending KAS to their L2 account via an L1 operation—sending KAS to a designated, well-known L2 address while specifying (via the payload) their destination address within L2. Complementarily, they should be able to initiate an “exit” request, demanding that the L2 send their BKAS
back from L2 to an L1 address.
Distinguishing semantics of Entry and Exit operations
From the perspective of L1, entry and exit operations serve fundamentally different purposes and feature distinct behaviors. An entry operation is an L1 transaction that is validated inline by L1 itself, making it effective immediately for both the transfer of funds on L1 and the corresponding state update on L2. In contrast, an exit operation requires internal L2 authorization, which cannot be directly validated by L1. Instead, it depends on the submission of a zero-knowledge proof (ZKP) to confirm its validity and take effect on L1.
This semantic distinction highlights a key operational difference: entry funds can be used immediately on L2, even before the entry operation has been formally proven via ZKP. For example, an entry operation could provide the necessary collateral for a subsequent proof and exit request by another user, even if its own proof is still pending. This immediate usability of entry funds reflects the simpler integration of entry operations, contrasting with the more complex requirements of exit operations.
Below, we dive into the technical details and complexities of designing a bridge to support entry and exit operations. Specifically, canonical bridges have been successfully designed in the industry and are relatively easy to come up with when the L1 is an SC layer as well. Our goal here is constructing such a bridge above the UTXO/scripting model. We focus on two key aspects:
- Using Kaspa’s scripting capabilities to form user-friendly entry addresses that can be easily used by users. This set of addresses and their corresponding UTXOs will effectively define an “L2 virtual wallet” which can be used by provers for managing L2-owned KAS funds on L1.
- Addressing the issue of many exit operations resulting from a single proof transaction, which might surpass local L1 transaction size limits.
Note: Exit operations are one type of outcome that must be transmitted from L2 to L1. More generally, this primitive is also required for asynchronous messages sent over L1 between rollup instances. These outcomes, verified by L1 as part of the ZKP check, attest to state reads within L2 and can be shared in plain text with other L2s. This process can be viewed as part of an abstract “outbox” that the prover is obligated to deliver to L1.
Static entry addresses and the primary state commitment UTXO
As detailed in part 1, the state commitment UTXO of a rollup instance contains an ever-changing dynamic p2sh
address. Conceptually, this address cannot be used as the receiving address for entry operations, both because it is short-lived (expiring with the next proof) and because spending it inherently requires a full ZKP
. Instead, we define static addresses that delegate their spending authorization to an existing ZKP
provided by primary proof scripts. This motivates the following introduction of static “delegation” p2sh
addresses, which directly resolve both issues: the address is no longer dynamic, and its spending signature no longer requires full ZKP
verification.
Specification of static delegation scripts
To focus on solving the foundational problem, I assume a single state commitment UTXO representing the full rollup state. Extending the discussion below to multiple state commitment UTXOs per rollup introduces additional complexities that require careful consideration and is therefore out of scope for this post.
Preliminaries
To establish a foundation for the discussion, let us revisit the definition of key components related to the state commitment process:
- Denote SCU_i as the i'th State Commitment UTXO of a given rollup instance since inception. In other words, [SCU_0, \dots, SCU_i, \dots, SCU_n] forms a chain of UTXOs where the i'th proof transaction spends SCU_{i-1} and creates SCU_i.
- Denote key(SCU_i) as the key identifier of this UTXO on L1. As per standard UTXO management, this key is composed of the following tuple (often referred to as the outpoint): (\text{prev tx id}, \text{output index}). In other words, the UTXO entry is uniquely identified by the transaction that outputs it and the index of that specific output within the transaction.
- Similarly, denote script(SCU_i) as the
p2sh
script specified within SCU_i. - Define the rollup’s canonical L1 script as
CANON
(see pseudo code below). - Recall that
PROG
is the hash of the permanent program L2 is obligated to execute.
Canonical state commitment script
To provide a more rigorous explanation of the canonical script’s role in verifying state commitments, its process is detailed below in pseudo-code (reiterating** parts of the code described in part 1 under ‘Proof transaction’). This expanded explanation also integrates the p2sh preimage verification step, ensuring a complete picture:
** Note that I’m adopting @hashdag’s terminology (here) and referring to the previously known history_merkle_root
as the sequencing_commitment
(seq_in
in the code below).
Inputs: script_hash, sig_script
-------------------------------
[<sig_data>, <redeem_script>] = sig_script
// Standard p2sh preimage verify
verify OpBlake2b(input: redeem_script) == script_hash
// Decompose redeem_script into its canonical script (only opcodes) and
// its data arguments.
// The L2 PROG will appear as a push data opcode in the canonical
// script, creating a strong binding between them. Additionally, the
// script encodes the incoming L2 state commitment (state_in), and
// the incoming L1 sequencing commitment (seq_in)
[<CANON>, [<PROG>, <state_in>, <seq_in>]] = redeem_script
// Execute the verified preimage script (converted into pseudo code for
// brevity).
// I.e., CANON is the actual pseudo code listed below in its stack-based
// script language format (this transfer to execution of the inner script
// is part of the standard p2sh processing)
Execute CANON(PROG, state_in, seq_in, sig_data):
// Extract arguments from signature data
[<out_script>, <chainblock_hash>, <zkp>] = sig_data
// Verify the output script preimage. OpTxOutputSpk is a new Kip10
// introspection opcode and is a crucial component in defining
// this "covenant" between the spending and the output scripts
verify OpBlake2b(input: out_script) == OpTxOutputSpk(index: 0)
// Decompose the out script, which is expected to be in the same format
// as the input script and deviate only in the dynamic elements (state
// and seq). Note that this decomposition might require new data-masking
// opcodes.
[<out_canon>, [<out_prog>, <state_out>, <seq_out>]] = out_script
// Verify PROG is preserved
verify out_prog == PROG
// Verify the canonical script itself is preserved (excluding
// the extracted variables state_out and seq_out). This can be
// thought of as the "covenant" by which the output script must
// follow the input script
verify out_canon == CANON
// Verify L1 sequencing root anchoring
verify OpChainBlockHistoryRoot(hash: chainblock_hash) == seq_out
// Verify the state transition ZK proof
OpZkVerify(prog: PROG, proof: zkp,
proof_pub_inputs: [state_in, state_out, seq_in, seq_out])
// ^ omitting other auxiliary verification data
The canonical script enforces the correctness of state transitions, ensuring both the input and output states, as well as sequencing commitments, are consistent with the L2 program (PROG
) and the ZKP
.
Static delegation script
To support entry operations without directly relying on dynamic state commitments, the following delegation script is proposed. This static script delegates certain responsibilities to the canonical script while remaining independent of the current state or history.
Inputs: script_hash, sig_script
-------------------------------
[<sig_data>, <redeem_script>] = sig_script
// Standard p2sh preimage verify
verify OpBlake2b(input: redeem_script) == script_hash
// Decompose redeem_script into its script part and data arguments
[<DELEGATE>, [<PROG>, <CANON>]] = redeem_script
// Execute the delegation script
Execute DELEGATE(PROG, CANON, sig_data):
[<primary_script>] = sig_data
// Verify primary script preimage
verify OpBlake2b(input: primary_script) == OpTxInputSpk(index: 0)
// Decompose primary script
[<canon_primary>, [<prog_primary>, ...]] = primary_script
// Verify PROG is preserved
verify prog_primary == PROG
// Verify the delegation targets a correct canonical script
verify canon_primary == CANON
The delegation scheme builds on the inherent property that a transaction is only accepted when all its inputs are validated as correctly spent. Through this mechanism, the delegator ensures that the primary input script meets specific requirements, authorizing its own spending if those conditions are satisfied.
The delegation script described above achieves a static structure by avoiding reliance on specific sequencing or dynamic state commitments, as shown by the redeem_script
preimage in the code snippet. However, without showcasing further properties, the scheme remains vulnerable to the following attack vector.
Attack vector. Alice sends funds from a standard L1 Schnorr address to a script structured correctly with CANON
and PROG
but using fabricated state
and seq
commitments. She then constructs a proof transaction, using the resulting UTXO as the primary input and adding additional inputs from static delegation addresses, redirecting the KAS funds to the primary state-commitment output. The result is that the funds are locked in a malformed state-commitment UTXO distinct from the authentic proof chain. Even if she uses a valid (state, seq)
pair, the problem persists as she has effectively created a separate proof chain.
The challenge. Addressing this attack requires incorporating information beyond the script itself. This is because L1 cannot enforce restrictions on the “entrance” to a script covenant—for instance, any user can send funds to an opaque p2sh
address. Consequently, authentic and forged state-commitment UTXOs are indistinguishable at the script level. Solutions based on static registration of rollup-associated information outside the UTXO set are currently ruled out, as they would introduce significant complexity and compromise the cleanliness and soundness of L1 state management.
Key-based authentication. An alternative solution involves leveraging UTXO keys to differentiate between authentic and non-authentic state-commitment UTXOs. A UTXO key is uniquely derived from the transaction that creates it, allowing the authenticity of a UTXO to be tied directly to its creating transaction. One possible approach relies on defining a genesis UTXO in the L2 source code and proving that the current UTXO input belongs to a transaction chain originating from this genesis. While requiring the entire proof chain as a witness in each new proof transition is impractical, a feasible alternative involves using a constant-length suffix of the proof chain, as outlined in the following section (in collaboration with @FreshAir08; based on preliminary discussions with @reshmem & @hashdag as well).
Key-based state-commitment UTXO authentication
We propose the following scheme as in illustrative example of a construction providing state-commitment authenticity.
- A
GENESIS
UTXO is hardcoded in L2 PROG via its source code - The
CANON
part of the canonical script is hardcoded in L2 PROG as well. Note that this is a pure script without any dependency on PROG (thus no hash cycles) - The
ZKP
receives as public input the key of the currently spent state-commitment UTXO, i.e., key(SCU_{i-1}) - The corresponding L2 prover program (producing this
ZKP
) receives as private witness the full i-2, i-1 proof transactions and the preimage of script(SCU_{i-2}) - The following logic is executed as part of the program:
// L2 program running for each proof transition
//
// Arguments: input_key - the UTXO key of the currently
// spent state-commitment UTXO
// source_tx - the source transaction of input_key
// (i.e., proof transaction i-1)
// source_script - the preimage of the script hash used as
// input for source_tx
// script_affirming_tx - proof transaction i-2; provided for
// affirming source_script (since the
// input of i-1 does not specify it)
PROG(...,pub_inputs: [..., input_key],
private_inputs: [..., source_tx, source_script, script_affirming_tx]):
...
// First, verify that input_key is indeed an output of source_tx
verify (blake2b(source_tx), 0) == input_key
if source_tx.inputs[0].outpoint == GENESIS:
// Allow entering the covenant only via the hardcoded genesis UTXO
pass
else:
// Otherwise, we must verify that input_key was produced as part of the
// covenant. To do that we acquire the incoming script through the i-2
// transaction, decompose it, and verify that it follows the expected
// canonical script
// Verify the linkage between the two transactions (i-2 -> i-1)
verify (blake2b(script_affirming_tx), 0) == source_tx.inputs[0].outpoint
// Verify that source_script is the preimage
verify blake2b(source_script) == script_affirming_tx.outputs[0].script
// Decompose the script
[<canon>, [<_prog>, <_state>, <_seq>]] = source_script
// Verify it follows the expected canonical script
verify canon == CANON
...
Definition 1: A state commitment UTXO is well-formed if its script preimage can be decomposed into [<CANON>, [<PROG>, <*>, <*>]]
.
Definition 2: A state commitment UTXO is forged if it hasn’t originated from the GENESIS
UTXO, i.e., GENESIS
was never part of a transaction chain leading to it.
Claim 1: A well-formed forged state commitment UTXO is unspendable.
Proof: Assume for contradiction that such a UTXO exists. Then there must be a maximal transaction X on the chain creating it, where the first input script S_1 is not well-formed (or at the very least missing altogether, since root transactions are always coinbase). By the maximality of X, it follows that the output of X, used as the first input for the following transaction, is well-formed. Denote this output as S_2.
There are two cases to consider:
- Case 1: S_1 is partially malformed
Here, S_1 can be decomposed into[<CANON>, [<PROG'>, <*>, <*>]]
, but it uses an incorrectPROG'
. In this case, X would fail L1 verification because S_2 will decompose into[<CANON>, [<PROG>, <*>, <*>]]
, and the L1CANON
execution would attempt to verify thatPROG'
is preserved, resulting in failure. - Case 2: S_1 is completely malformed
Here, S_1 cannot even be decomposed into[<CANON>, [<*>, <*>, <*>]]
. However, S_2 is well-formed. From our contradiction assumption, S_2 must be spendable. The spender must provide a validZKP
based on the realPROG
. However, the prover must include X as a witness, and duringPROG
execution, both branches will fail:
(i) X's first input key cannot beGENESIS
, as it is forged; and
(ii) S_1 cannot be decomposed intoCANON
, so it will fail the final line of execution.
Corollary: Delegation scripts cannot be redirected to a forged proof chain.
Proof: The last line of DELEGATE
verifies that the primary input script is well-formed, thus by Claim 1 it can only be spent if it is not forged.
L2 initialization procedure
To initialize an L2 system with this scheme, the process begins by sending KAS on L1 to an ordinary address (e.g., Schnorr) controlled by L2 initiators. The resulting UTXO key from this transaction is then added to the L2 source code and designated as GENESIS
. This key serves as the starting point for the rollup’s transaction chain.
The next step is to create the canonical script CANON
and add it as a constant to the L2 source code. While CANON
is not directly part of the L2 system, it is essential for enabling chain link verification between transactions. Once CANON
is defined, the L2 program is compiled to produce PROG
, which serves as the main logic for generating proofs.
Using CANON
, PROG
, and the initial state
and seq
, the initial state-commitment script is composed. This script represents the starting point for the rollup’s state and sequencing commitments. An L1 transaction is then performed to transfer funds from the GENESIS
UTXO to the newly created state-commitment script, marking the rollup’s formal initiation.
After the transaction is confirmed, a proof of its acceptance on L1 should be saved as part of the L2’s integrity data. This proof can also be shared with new L2 nodes to establish trust in the L2 initialization process. The combination of L1-approved proof transactions and L2-verified chain links ensures that only authenticated state-commitment UTXOs pass ZKP
verification on L1, effectively mitigating the attack vector of redirecting delegated address funds.
Syncing new L2 nodes
This scheme provides a trustless mechanism for fully syncing new L2 nodes from the recent state. By verifying the correct state-commitment UTXO and the suffix of the transaction chain leading to it (e.g., the last two transactions), new nodes can use the authenticated L2 state commitment on L1 to confirm the newly synced L2 state is consistent with this commitment.
Exit operations as proof outputs
(Thx @FreshAir08 for writing the majority of this section.)
After a user issues a “withdrawal” transaction to L2, the associated funds are no longer available in L2 but remain in the L2-owned addresses. When a proof is submitted, it must ensure and enforce the transfer of these funds from the addresses to the requested L1 address.
Conceptually, these pending withdrawal transactions form an outbox of exit operations, which can be inferred from the executed L1 transactions within the proved period. To support this mechanism, we propose the following additions:
- L1
CANON
script change: modifyCANON
to compute the cumulative hash of all outputs in the current transaction except the primary state commitment output (similar to the Schnorr sighash process). This hash will be passed toOpZkVerify
as an additional public proof input. - L2
PROG
change: updatePROG
to compile a list of expected proof outputs from the outbox and compute their cumulative hash using the same method employed byCANON
. This hash is then verified against the public input provided in the proof.
N to Const problem
So far, we have assumed that the prover can include all pending exit outputs in a single proof transaction. However, due to mass limitations (e.g., KIP9), these added outputs could potentially take up a significant amount of block space. While higher fees for the prover or slower L2 progress are concerns, the main issue is that the transaction might exceed the block size limit, causing a deadlock in the L2. In extreme cases, even a single mergeset could congest an entire block.
We explore two possible solutions to address this issue:
-
Instead of directly including all L1 addresses in outputs, public keys (or more general scripts) can be used. Provers would transfer funds to special-purpose
p2sh
addresses that represent all public keys associated with the withdrawals. These addresses would allow each public key to spend only its share of the funds, similar to a KIP10-like mechanism. The public keys could be stored in a Merkle tree within the redeem script, and an additional opcode might be added to verify Merkle witnesses efficiently. -
If funds are not immediately repatriated to L1, they can remain in a designated L2 outbox, which acts as an extension to the L2 state. The program would enforce that the active L2 state cannot advance until the outbox has been cleared. If the outbox becomes too congested, the L1 sequencing commitment would remain unchanged, and only the outbox would be updated. Adapting the hash commitments discussed earlier to this structure would be straightforward.
A more lenient variation of this idea could allow state advancement with partial outbox clearing. For example, provers might be required to clear the outbox at twice the rate they add to it. This restriction could activate only once the outbox exceeds a predefined congestion limit.