Skip to content
9 changes: 6 additions & 3 deletions apps/wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1967,9 +1967,12 @@ fn kernel_message_kind(message: &KernelInboxMessage) -> RollupSubmissionKind {
KernelInboxMessage::Shield(_) => RollupSubmissionKind::Shield,
KernelInboxMessage::Transfer(_) => RollupSubmissionKind::Transfer,
KernelInboxMessage::Unshield(_) => RollupSubmissionKind::Unshield,
KernelInboxMessage::Withdraw(_)
| KernelInboxMessage::ConfigureVerifier(_)
| KernelInboxMessage::ConfigureBridge(_) => RollupSubmissionKind::Withdraw,
KernelInboxMessage::Withdraw(_) => RollupSubmissionKind::Withdraw,
KernelInboxMessage::ConfigureVerifier(_)
| KernelInboxMessage::ConfigureBridge(_) => unreachable!(
"wallet does not submit admin Configure* messages; they flow \
through `octez_kernel_message` + `octez-client` directly"
),
KernelInboxMessage::DalPointer(_) => {
unreachable!("wallet should not submit raw DAL pointer messages")
}
Expand Down
343 changes: 342 additions & 1 deletion core/src/kernel_wire.rs

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions docs/shadownet_tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,18 @@ Notes:

## 6. Fund Alice On L1 And Wait For The Public Rollup Balance

Deposit into the bridge for Alice’s public rollup account:
Deposit into the bridge for Alice’s public rollup account. The shield
in the next step debits `v + fee + producer_fee` from the public
balance (200000 + 100000 + 1 = 300001 mutez), so the deposit must cover
that total — depositing just `v` (200000) or `v + burn` (300000) leaves
the shield short by the DAL-producer fee and it fails with
"insufficient balance".

```bash
/usr/local/bin/tzel-wallet \
--wallet alice.wallet \
deposit \
--amount 300000 \
--amount 300001 \
--public-account alice
```

Expand All @@ -266,7 +271,7 @@ The wallet prints an L1 operation hash. Wait for it to land, then poll:
Do not continue until Alice shows a non-zero line like:

```text
Public rollup balance (alice): 300000
Public rollup balance (alice): 300001
```

## 7. Shield Alice’s Funds
Expand Down
113 changes: 88 additions & 25 deletions scripts/octez_rollup_sandbox_dal_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,14 @@ data.pop("chain_id", None)
data.pop("initial_timestamp", None)
data["minimal_block_delay"] = "1"
data["delay_increment_per_round"] = "1"
data.setdefault("dal_parametric", {})["attestation_lag"] = int(attestation_lag)
dal = data.setdefault("dal_parametric", {})
dal["attestation_lag"] = int(attestation_lag)
# Protocol constraint (added by tezos master 8499ce19ac on 2025-12-04):
# The last element of attestation_lags must equal attestation_lag.
# Default mockup populates attestation_lags with [1,2,3,4,5], breaking the
# invariant when attestation_lag is overridden to anything other than 5.
# Force a single-element list here.
dal["attestation_lags"] = [int(attestation_lag)]

with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True)
Expand Down Expand Up @@ -263,8 +270,39 @@ build_kernel_and_tools() {
fi
rustup target list --installed "${rustup_toolchain_args[@]}" | grep -qx 'wasm32-unknown-unknown' \
|| rustup target add "${rustup_toolchain_args[@]}" wasm32-unknown-unknown >/dev/null
cargo "${cargo_toolchain_args[@]}" build -q -p tzel-rollup-kernel --target wasm32-unknown-unknown --release "${kernel_cargo_args[@]}"

# Build octez_kernel_message first so we can derive admin material from
# a fresh random ask. The release kernel WASM rejects admin config
# messages unless the matching pub-seed/leaves are baked in at compile
# time via TZEL_ROLLUP_CONFIG_ADMIN_*_HEX env vars. Mirror the flow of
# scripts/build_rollup_kernel_release.sh.
cargo "${cargo_toolchain_args[@]}" build -q -p tzel-rollup-kernel --bin octez_kernel_message --bin verified_bridge_fixture_message "${kernel_cargo_args[@]}"

local admin_state_dir="${WORKDIR}/rollup-config-admin"
"${ROOT}/scripts/prepare_rollup_config_admin.sh" \
--workspace-root "${ROOT}" \
--state-dir "${admin_state_dir}" \
--octez-kernel-message "${ROOT}/target/debug/octez_kernel_message" \
>/dev/null

# Load the secret ask into this shell so configure-{verifier,bridge}[-payload]
# CLI calls sign with the matching key the kernel will have baked in.
# The `set -a` propagates TZEL_ROLLUP_CONFIG_ADMIN_ASK_HEX into every
# descendant process. This is fine inside a sandbox run (the ask is
# generated fresh per WORKDIR and discarded on exit) but DO NOT copy
# this pattern to production runners — in shadownet / mainnet, the ask
# should be read at invocation time and not inherited by unrelated
# child processes.
# shellcheck disable=SC1090
set -a
source "${admin_state_dir}/rollup-config-admin-runtime.env"
source "${admin_state_dir}/rollup-config-admin-build.env"
set +a

# Build the release kernel WASM — the TZEL_ROLLUP_CONFIG_ADMIN_*_HEX env
# vars sourced above are picked up by option_env!() in the kernel source
# and baked into the WASM blob.
cargo "${cargo_toolchain_args[@]}" build -q -p tzel-rollup-kernel --target wasm32-unknown-unknown --release "${kernel_cargo_args[@]}"
}

fixture_metadata() {
Expand All @@ -285,7 +323,7 @@ print(data["shield_program_hash"])
print(data["transfer_program_hash"])
print(data["unshield_program_hash"])
print(data["shield_sender"])
print(data["shield_amount"])
print(data["shield_bridge_deposit"])
' <<<"${metadata_json}"
}

Expand Down Expand Up @@ -364,26 +402,33 @@ start_rollup_node() {
}

send_configure_verifier_message() {
# ConfigureVerifier is WOTS-signed and larger than
# sc_rollup_message_size_limit (4096 bytes), so it cannot be sent via
# the direct L1 external-message path. We publish the raw
# KernelInboxMessage bytes to DAL and inject a DalPointer on L1.
local rollup_address="$1"
local auth_domain="$2"
local shield_hash="$3"
local transfer_hash="$4"
local unshield_hash="$5"
local message_hex
message_hex="$("${ROOT}/target/debug/octez_kernel_message" configure-verifier "${rollup_address}" "${auth_domain}" "${shield_hash}" "${transfer_hash}" "${unshield_hash}")"
octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \
send smart rollup message "hex:[ \"${message_hex}\" ]" from operator >/dev/null
bake_block with-dal
local payload_file
payload_file="${WORKDIR}/configure-verifier-payload.bin"
"${ROOT}/target/debug/octez_kernel_message" configure-verifier-payload \
"${auth_domain}" "${shield_hash}" "${transfer_hash}" "${unshield_hash}" \
| xxd -r -p > "${payload_file}"
publish_payload_via_dal_and_inject_pointer configure_verifier "${rollup_address}" "${payload_file}"
}

send_configure_bridge_message() {
# Same reason as send_configure_verifier_message: WOTS-signed, oversized,
# routed via DAL.
local rollup_address="$1"
local ticketer="$2"
local message_hex
message_hex="$("${ROOT}/target/debug/octez_kernel_message" configure-bridge "${rollup_address}" "${ticketer}")"
octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \
send smart rollup message "hex:[ \"${message_hex}\" ]" from operator >/dev/null
bake_block with-dal
local payload_file
payload_file="${WORKDIR}/configure-bridge-payload.bin"
"${ROOT}/target/debug/octez_kernel_message" configure-bridge-payload "${ticketer}" \
| xxd -r -p > "${payload_file}"
publish_payload_via_dal_and_inject_pointer configure_bridge "${rollup_address}" "${payload_file}"
}

read_rollup_u64() {
Expand Down Expand Up @@ -426,7 +471,9 @@ await_rollup_u64() {
await_bridge_ticketer() {
local ticketer="$1"
local encoded_ticketer response
encoded_ticketer="$(printf '%s' "${ticketer}" | xxd -ps -c 0)"
# `xxd -ps -c 0` wraps at ~60 chars on some xxd versions despite the
# docs; strip newlines to get a single hex string.
encoded_ticketer="$(printf '%s' "${ticketer}" | xxd -ps -c 0 | tr -d '\n')"
local url="${ROLLUP_ENDPOINT}/global/block/head/durable/wasm_2_0_0/value?key=/tzel/v1/state/bridge/ticketer"
local i
for ((i = 0; i < 180; i++)); do
Expand All @@ -446,7 +493,7 @@ deposit_to_bridge() {
local recipient="$3"
local amount_mutez="$4"
local recipient_hex tez_amount
recipient_hex="$(printf '%s' "${recipient}" | xxd -ps -c 0)"
recipient_hex="$(printf '%s' "${recipient}" | xxd -ps -c 0 | tr -d '\n')"
tez_amount="$(mutez_to_tez "${amount_mutez}")"
octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \
transfer "${tez_amount}" from operator to "${ticketer}" \
Expand Down Expand Up @@ -551,9 +598,13 @@ await_dal_attested() {
return 1
}

publish_shield_via_dal_and_inject_pointer() {
local rollup_address="$1"
local payload_file="$2"
publish_payload_via_dal_and_inject_pointer() {
# kind must be one of the tokens accepted by
# `octez_kernel_message dal-pointer`: shield, transfer, unshield,
# configure_verifier, configure_bridge.
local kind="$1"
local rollup_address="$2"
local payload_file="$3"
local payload_len payload_hash number_of_slots slot_size
payload_len="$(stat -c%s "${payload_file}")"
payload_hash="$(payload_hash_hex "${payload_file}")"
Expand Down Expand Up @@ -582,12 +633,16 @@ print(data["commitment_proof"])
done < "${DAL_CHUNKS_FILE}"

local message_hex
message_hex="$("${ROOT}/target/debug/octez_kernel_message" dal-pointer "${rollup_address}" shield "${payload_hash}" "${payload_len}" "${pointer_args[@]}")"
message_hex="$("${ROOT}/target/debug/octez_kernel_message" dal-pointer "${rollup_address}" "${kind}" "${payload_hash}" "${payload_len}" "${pointer_args[@]}")"
octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \
send smart rollup message "hex:[ \"${message_hex}\" ]" from operator >/dev/null
bake_block with-dal
}

publish_shield_via_dal_and_inject_pointer() {
publish_payload_via_dal_and_inject_pointer shield "$@"
}

main() {
prepare_client_material
init_node
Expand All @@ -612,31 +667,39 @@ main() {
local fixture_fields
fixture_fields="$(extract_fixture_fields "$(fixture_metadata)")"
mapfile -t fixture_lines <<<"${fixture_fields}"
local auth_domain_hex shield_hash_hex transfer_hash_hex unshield_hash_hex shield_sender shield_amount
local auth_domain_hex shield_hash_hex transfer_hash_hex unshield_hash_hex shield_sender shield_bridge_deposit
auth_domain_hex="${fixture_lines[0]}"
shield_hash_hex="${fixture_lines[1]}"
transfer_hash_hex="${fixture_lines[2]}"
unshield_hash_hex="${fixture_lines[3]}"
shield_sender="${fixture_lines[4]}"
shield_amount="${fixture_lines[5]}"
# `apply_shield` debits `v + fee + producer_fee` from the sender's public
# balance, not just `v`. The fixture-message binary exposes the total
# under `shield_bridge_deposit`; the sandbox uses that to size the bridge
# deposit so the post-shield balance lands at zero.
shield_bridge_deposit="${fixture_lines[5]}"

send_configure_verifier_message "${rollup_address}" "${auth_domain_hex}" "${shield_hash_hex}" "${transfer_hash_hex}" "${unshield_hash_hex}"
send_configure_bridge_message "${rollup_address}" "${ticketer_address}"
await_bridge_ticketer "${ticketer_address}"

deposit_to_bridge "${ticketer_address}" "${rollup_address}" "${shield_sender}" "${shield_amount}"
deposit_to_bridge "${ticketer_address}" "${rollup_address}" "${shield_sender}" "${shield_bridge_deposit}"

local balance_key
balance_key="/tzel/v1/state/balances/by-key/$(printf '%s' "${shield_sender}" | xxd -ps -c 0)"
await_rollup_u64 "${balance_key}" "${shield_amount}" "public bridge balance"
balance_key="/tzel/v1/state/balances/by-key/$(printf '%s' "${shield_sender}" | xxd -ps -c 0 | tr -d '\n')"
await_rollup_u64 "${balance_key}" "${shield_bridge_deposit}" "public bridge balance"

local shield_payload_file
shield_payload_file="${WORKDIR}/shield-payload.bin"
fixture_shield_raw_hex | xxd -r -p > "${shield_payload_file}"
publish_shield_via_dal_and_inject_pointer "${rollup_address}" "${shield_payload_file}"

await_rollup_u64 "${balance_key}" "0" "public balance drain after shield"
await_rollup_u64 "/tzel/v1/state/tree/size" "1" "shielded note insertion"
# `apply_shield` appends two notes to the Merkle tree: the sender's own
# commitment and the producer's compensation commitment (bde1347 added
# the producer output). Expecting size 1 here — the pre-fees value —
# stalls the smoke even though the shield applied cleanly.
await_rollup_u64 "/tzel/v1/state/tree/size" "2" "shielded note insertion"

echo "octez rollup sandbox DAL smoke passed"
echo "rollup=${rollup_address}"
Expand Down
78 changes: 78 additions & 0 deletions tezos/rollup-kernel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,84 @@ Supported message kinds:

These messages are applied through the shared Rust transition logic in `core/`.

## Admin configuration messages and DAL routing

Two admin-signed messages configure the rollup post-origination:

- `ConfigureVerifier` — sets the expected Cairo program hashes (shield /
transfer / unshield) and the STARK auth domain.
- `ConfigureBridge` — sets the KT1 ticketer contract whose tickets the
kernel will accept as legitimate deposit receipts.

Both are signed with a WOTS one-time signature authenticated by a leaf
baked into the kernel WASM at build time (`admin-material`,
`TZEL_ROLLUP_{VERIFIER,BRIDGE}_CONFIG_ADMIN_LEAF_HEX`). The WOTS
signature accounts for most of the message size (`WOTS_CHAINS × F` =
133 × 32 = 4256 bytes).

### Delivery invariant

The Tezos protocol constant `sc_rollup_message_size_limit` caps L1 inbox
external messages at **4096 bytes**. Both admin config messages exceed
this limit once signed:

| Message | Serialized size | L1 inbox viable? |
|----------------------|----------------:|:-----------------|
| `ConfigureVerifier` | 4923 bytes | ❌ must use DAL |
| `ConfigureBridge` | 4835 bytes | ❌ must use DAL |

They are therefore routed through the DAL delivery path, same as
`Shield`, `Transfer`, `Unshield`. The flow:

1. Operator computes the unframed `KernelInboxMessage` bytes via the
`configure-{verifier,bridge}-payload` subcommands of
`octez_kernel_message`.
2. Operator chunks the bytes, publishes them as DAL slots, waits for
attestation.
3. Operator injects into the L1 inbox a small `DalPointer` message
(framed via `ExternalMessageFrame::Targetted`) whose `kind` field
(`configure_verifier` / `configure_bridge`) tells the kernel how to
interpret the DAL payload.
4. Kernel's `fetch_kernel_message_from_dal` reassembles the chunks,
verifies the hash, decodes as `KernelInboxMessage`, and dispatches
based on `pointer.kind ↔ message` consistency.

### Adding a new oversized message type

If a future message exceeds 4096 bytes and must reach the kernel:

1. Add a variant to `KernelDalPayloadKind` in `core/src/kernel_wire.rs`
(next free wire tag).
2. Add the reciprocal arm in `fetch_kernel_message_from_dal` in this
crate's `lib.rs` and in `dal_payload_kind_name`. The outer match
is exhaustive on `KernelDalPayloadKind`, so the compiler will
refuse to build until both arms are present.
3. Decide which submission path applies:
- **User-facing payloads** (Shield / Transfer / Unshield and
similar): mirror the variant in `RollupSubmissionKind` and the
operator's `kernel_message_matches_submission_kind` /
`dal_pointer_from_submission`, so the wallet can submit via the
operator.
- **Admin-signed payloads** (`Configure*` and anything else
authenticated by the config-admin WOTS key): do **not** route
through the operator. Admin messages are injected directly with
`octez_kernel_message` + `octez-client send smart rollup
message`, using the admin's own L1 key and WOTS ask. This keeps
the operator surface narrow and prevents a bearer-token leak
from granting admin injection.
4. Update the `octez_kernel_message` CLI: add a `<cmd>-payload`
subcommand that outputs the raw unframed bytes, and extend
`parse_dal_kind` with the new token.
5. Add a size-sentinel test under `core/src/kernel_wire.rs::tests`
(see `configure_verifier_serialized_size_sentinel`). The
variant-exhaustive test `inbox_size_invariant_covers_all_variants`
will also refuse to build until the new variant is classified as
`FitsL1` or `RequiresDal` in its `required_routing`.

If a change *reduces* an existing message below 4096 bytes, the direct
L1 path becomes usable again but the DAL path can remain for uniformity
— review on a case-by-case basis.

The kernel does not keep the full ledger as one serialized blob. It stores:
- note records under append-only per-index paths
- the commitment-tree append frontier and current root
Expand Down
38 changes: 38 additions & 0 deletions tezos/rollup-kernel/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// The kernel bakes admin material (config-admin public seed + the two WOTS
// leaf hashes for the verifier/bridge config keys) into the WASM at compile
// time via `option_env!()` in src/lib.rs. These values authenticate admin
// `Configure*` messages — change them and the set of admin signatures the
// kernel will accept changes with them.
//
// Cargo's default fingerprint tracks source files, Cargo.toml, RUSTFLAGS,
// and features — but NOT the values of ad-hoc environment variables read
// through `option_env!()`. So this sequence silently produces a broken
// artifact:
//
// 1. Operator regenerates admin material (new `ask`) and exports new
// `TZEL_ROLLUP_*_HEX` values.
// 2. `cargo build` sees no source change → reuses the cached WASM from
// the previous ask.
// 3. Kernel is deployed; it holds the OLD pub_seed/leaves.
// 4. Admin signs Configure messages with the NEW ask.
// 5. Kernel rejects every admin signature as invalid — no compile error,
// no startup panic, just silent DoS on admin ops. Only symptom is
// `ConfigureVerifier` / `ConfigureBridge` messages being rejected in
// the rollup inbox with a signature-verification failure.
//
// The directives below extend cargo's fingerprint to include these three
// env vars, so any change to them forces a rebuild and the newly-baked
// WASM matches the currently-exported admin material.
//
// Note: `TZEL_ROLLUP_CONFIG_ADMIN_ASK_HEX` is deliberately NOT tracked —
// the `ask` is a runtime signing input read by `octez_kernel_message`, not
// a compile-time kernel input. Only the derived public material is baked.
fn main() {
for var in [
"TZEL_ROLLUP_CONFIG_ADMIN_PUB_SEED_HEX",
"TZEL_ROLLUP_VERIFIER_CONFIG_ADMIN_LEAF_HEX",
"TZEL_ROLLUP_BRIDGE_CONFIG_ADMIN_LEAF_HEX",
] {
println!("cargo:rerun-if-env-changed={}", var);
}
}
Loading
Loading