diff --git a/apps/wallet/src/lib.rs b/apps/wallet/src/lib.rs index 6e8c6a6..8749bf5 100644 --- a/apps/wallet/src/lib.rs +++ b/apps/wallet/src/lib.rs @@ -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") } diff --git a/core/src/kernel_wire.rs b/core/src/kernel_wire.rs index 211d990..4c0846d 100644 --- a/core/src/kernel_wire.rs +++ b/core/src/kernel_wire.rs @@ -11,7 +11,9 @@ use tezos_data_encoding::enc::BinWriter; use tezos_data_encoding::encoding::HasEncoding; use tezos_data_encoding::nom::NomReader; -pub const KERNEL_WIRE_VERSION: u16 = 9; +// v10: KernelDalPayloadKind gained ConfigureVerifier (tag 3) and +// ConfigureBridge (tag 4) variants. +pub const KERNEL_WIRE_VERSION: u16 = 10; pub const KERNEL_VERIFIER_CONFIG_KEY_INDEX: u32 = 0; pub const KERNEL_BRIDGE_CONFIG_KEY_INDEX: u32 = 1; const MAX_ACCOUNT_ID_BYTES: usize = 1024; @@ -117,6 +119,14 @@ pub enum KernelDalPayloadKind { Shield, Transfer, Unshield, + /// Admin configuration of the STARK verifier. Carried via DAL because + /// the WOTS-signed payload exceeds `sc_rollup_message_size_limit` + /// (4096 bytes). See the size sentinel in the `tests` module. + ConfigureVerifier, + /// Admin configuration of the bridge ticketer. Also WOTS-signed and + /// therefore oversized for the L1 inbox — routed via DAL for the same + /// reason. + ConfigureBridge, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -277,6 +287,11 @@ struct WireKernelWithdrawReq { #[derive(Debug, Clone, PartialEq, Eq, HasEncoding, NomReader, BinWriter)] #[encoding(tags = "u8")] +// Tag assignments here are independent of `WireKernelInboxMessage` below: +// a `KernelDalPayloadKind` labels a *DAL-routed* message by category, +// whereas `WireKernelInboxMessage` tags every kernel inbox message +// including ones that never transit through DAL (Withdraw, DalPointer). +// Do not assume numeric correspondence between the two spaces. enum WireKernelDalPayloadKind { #[encoding(tag = 0)] Shield, @@ -284,6 +299,10 @@ enum WireKernelDalPayloadKind { Transfer, #[encoding(tag = 2)] Unshield, + #[encoding(tag = 3)] + ConfigureVerifier, + #[encoding(tag = 4)] + ConfigureBridge, } #[derive(Debug, Clone, PartialEq, Eq, HasEncoding, NomReader, BinWriter)] @@ -701,6 +720,8 @@ fn kernel_dal_payload_kind_to_wire(kind: &KernelDalPayloadKind) -> WireKernelDal KernelDalPayloadKind::Shield => WireKernelDalPayloadKind::Shield, KernelDalPayloadKind::Transfer => WireKernelDalPayloadKind::Transfer, KernelDalPayloadKind::Unshield => WireKernelDalPayloadKind::Unshield, + KernelDalPayloadKind::ConfigureVerifier => WireKernelDalPayloadKind::ConfigureVerifier, + KernelDalPayloadKind::ConfigureBridge => WireKernelDalPayloadKind::ConfigureBridge, } } @@ -711,6 +732,8 @@ fn kernel_dal_payload_kind_from_wire( WireKernelDalPayloadKind::Shield => KernelDalPayloadKind::Shield, WireKernelDalPayloadKind::Transfer => KernelDalPayloadKind::Transfer, WireKernelDalPayloadKind::Unshield => KernelDalPayloadKind::Unshield, + WireKernelDalPayloadKind::ConfigureVerifier => KernelDalPayloadKind::ConfigureVerifier, + WireKernelDalPayloadKind::ConfigureBridge => KernelDalPayloadKind::ConfigureBridge, }) } @@ -2400,4 +2423,322 @@ mod tests { .unwrap_err(); assert!(err.contains("bad ct_d length")); } + + // ────────────────────────────────────────────────────────────────── + // Size sentinels for admin config messages. + // + // These tests lock the serialized byte length of a typical + // `ConfigureVerifier` / `ConfigureBridge` `KernelInboxMessage`. They + // are expected to pass in the current tree; they exist to trigger a + // review when the encoding changes (new field in the config, WOTS + // parameter change, struct reshuffling, etc.). + // + // Why the byte budget matters: + // - Messages routed through the L1 inbox are subject to the protocol + // constant `sc_rollup_message_size_limit = 4096`. + // - Messages routed through DAL can go up to `MAX_DAL_PAYLOAD_BYTES` + // (several hundred kB), and the kernel recovers the payload via a + // `DalPointer` whose routable kinds are enumerated in + // `KernelDalPayloadKind`. + // + // If a sentinel below breaks: + // 1. Read the new size from the assertion output. + // 2. If it still exceeds 4096 bytes: confirm that the corresponding + // `KernelDalPayloadKind` variant and the match arm in + // `tezos/rollup-kernel/src/lib.rs::fetch_kernel_message_from_dal` + // still exist. Otherwise the message becomes impossible to + // deliver to the kernel. + // 3. If it now fits in 4096 bytes: the DAL route remains correct + // but also becomes optional for this message type. + // 4. Update the `EXPECTED_*` constant to the new value. + // ────────────────────────────────────────────────────────────────── + + /// The L1 smart-rollup external message size limit, as defined by the + /// Tezos protocol (`Constants_repr.sc_rollup_message_size_limit`). + /// Duplicated here because this crate does not depend on the protocol. + const L1_INBOX_MESSAGE_LIMIT: usize = 4096; + + #[test] + fn configure_verifier_serialized_size_sentinel() { + const EXPECTED_SIZE: usize = 4923; + + let ask = crate::hash(b"tzel-dev-rollup-config-admin"); + let config = KernelVerifierConfig { + auth_domain: [0xAA; 32], + verified_program_hashes: ProgramHashes { + shield: [0xBB; 32], + transfer: [0xCC; 32], + unshield: [0xDD; 32], + }, + }; + let signed = sign_kernel_verifier_config(&ask, config) + .expect("sign verifier config"); + let encoded = encode_kernel_inbox_message( + &KernelInboxMessage::ConfigureVerifier(signed), + ) + .expect("encode configure-verifier message"); + + assert_eq!( + encoded.len(), + EXPECTED_SIZE, + "ConfigureVerifier serialized size changed — see module comment", + ); + assert!( + encoded.len() > L1_INBOX_MESSAGE_LIMIT, + "ConfigureVerifier now fits in L1 inbox ({} <= {}); the DAL \ + route can remain but is no longer required", + encoded.len(), + L1_INBOX_MESSAGE_LIMIT, + ); + } + + #[test] + fn configure_bridge_serialized_size_sentinel() { + const EXPECTED_SIZE: usize = 4835; + + // A typical KT1 Tezos contract address (36 characters). + let ticketer = "KT1Fq8fPi2NjhWUXtcXBggbL6zFjZctGkmso".to_string(); + + let ask = crate::hash(b"tzel-dev-rollup-config-admin"); + let signed = sign_kernel_bridge_config(&ask, KernelBridgeConfig { ticketer }) + .expect("sign bridge config"); + let encoded = encode_kernel_inbox_message( + &KernelInboxMessage::ConfigureBridge(signed), + ) + .expect("encode configure-bridge message"); + + assert_eq!( + encoded.len(), + EXPECTED_SIZE, + "ConfigureBridge serialized size changed — see module comment", + ); + assert!( + encoded.len() > L1_INBOX_MESSAGE_LIMIT, + "ConfigureBridge now fits in L1 inbox ({} <= {}); the DAL \ + route can remain but is no longer required", + encoded.len(), + L1_INBOX_MESSAGE_LIMIT, + ); + } + + // ────────────────────────────────────────────────────────────────── + // Variant-exhaustive framed-size invariant. + // + // The two sentinels above lock exact byte counts for the admin + // config messages. This test is broader: for **every** variant of + // `KernelInboxMessage`, it checks the on-wire size (after wrapping + // in `ExternalMessageFrame::Targetted` — the actual bytes + // `octez-client send smart rollup message` transmits) against the + // routing the kernel + tooling assume for that variant. + // + // Why it exists in addition to the sentinels: + // - It measures the **framed** size. The protocol caps the + // framed bytes at `sc_rollup_message_size_limit = 4096`, and + // the frame adds 21 bytes (1 tag + 20 bytes rollup-address + // hash) on top of `encode_kernel_inbox_message`. A message + // that sits just below 4096 unframed can still be rejected by + // the L1 inbox. + // - It is **exhaustive on `KernelInboxMessage` at compile time** + // via `required_routing`. When a future commit adds a new + // variant (mirroring 2c45d9c, which added WOTS signatures that + // silently grew `Configure*` past 4096 without any test + // failing), the author is forced to classify the new variant + // as `FitsL1` or `RequiresDal` — there is no `_ =>` arm to + // hide behind. + // - It is **two-sided**: a variant that shrinks below 4096 after + // being classified `RequiresDal` also fails the test, flagging + // a dead DAL path that should be either kept intentionally or + // removed. + // + // When this test fails: + // - The assertion message tells you which variant broke which + // direction. Either rebuild the representative instance to + // match today's size, update the `required_routing` + // classification, or prune the DAL routing for a variant that + // no longer needs it. + // ────────────────────────────────────────────────────────────────── + + #[derive(Debug, PartialEq, Eq)] + enum Routing { + /// Framed size must stay `<= L1_INBOX_MESSAGE_LIMIT` — the + /// message is routed directly through the L1 rollup inbox. + FitsL1, + /// Framed size must stay `> L1_INBOX_MESSAGE_LIMIT` — the + /// message is chunked and routed via DAL, then referenced from + /// L1 via a `DalPointer`. If a `RequiresDal` variant ever + /// shrinks to fit in L1, the DAL routing for it becomes dead + /// code and the classification needs to be reconsidered. + RequiresDal, + } + + fn required_routing(message: &KernelInboxMessage) -> Routing { + // Exhaustive match on purpose: any new `KernelInboxMessage` + // variant MUST be classified here, forcing the author to + // decide whether it fits in L1 or needs DAL chunking. + match message { + KernelInboxMessage::ConfigureVerifier(_) + | KernelInboxMessage::ConfigureBridge(_) => Routing::RequiresDal, + KernelInboxMessage::Shield(_) + | KernelInboxMessage::Transfer(_) + | KernelInboxMessage::Unshield(_) => Routing::RequiresDal, + KernelInboxMessage::Withdraw(_) => Routing::FitsL1, + KernelInboxMessage::DalPointer(_) => Routing::FitsL1, + } + } + + /// Frame overhead added by `ExternalMessageFrame::Targetted` on + /// top of the raw `encode_kernel_inbox_message` bytes when + /// `octez-client send smart rollup message` injects a payload. + /// + /// Layout: + /// - 1 byte for the `Targetted` tag + /// - 20 bytes for the `SmartRollupHash` (no length prefix on + /// wire; the type is fixed-size) + /// + /// Verified empirically against the hex output of + /// `octez_kernel_message dal-pointer …` on a valid sr1 address. + /// Replicated here to avoid dragging + /// `tezos-smart-rollup-encoding` (which pins a different + /// `tezos_data_encoding` major than `core`'s direct dep) into + /// this crate's test deps. + const EXTERNAL_MESSAGE_FRAME_OVERHEAD: usize = 21; + + /// Return the on-wire size the L1 inbox sees for this message, + /// i.e. `encode_kernel_inbox_message(...).len()` plus the fixed + /// `ExternalMessageFrame::Targetted` overhead. This is the + /// value subject to `sc_rollup_message_size_limit = 4096`. + fn framed_len(message: &KernelInboxMessage) -> usize { + let payload = encode_kernel_inbox_message(message).expect("encode message"); + payload.len() + EXTERNAL_MESSAGE_FRAME_OVERHEAD + } + + /// A proof large enough to push Shield/Transfer/Unshield over the + /// L1 size limit once framed. Production STARK proofs are + /// hundreds of kilobytes; 4096 bytes of filler is the cheapest + /// size that makes the RequiresDal classification hold + /// unambiguously while staying well below `MAX_PROOF_BYTES`. + fn oversize_kernel_stark_proof() -> KernelStarkProof { + KernelStarkProof { + proof_bytes: vec![0xaa; 4096], + output_preimage: vec![[7u8; 32]], + verify_meta: vec![1, 2, 3, 4], + } + } + + #[test] + fn inbox_size_invariant_covers_all_variants() { + let ask = crate::hash(b"tzel-dev-rollup-config-admin"); + + let configure_verifier = KernelInboxMessage::ConfigureVerifier( + sign_kernel_verifier_config( + &ask, + KernelVerifierConfig { + auth_domain: [0xAA; 32], + verified_program_hashes: ProgramHashes { + shield: [0xBB; 32], + transfer: [0xCC; 32], + unshield: [0xDD; 32], + }, + }, + ) + .expect("sign verifier"), + ); + let configure_bridge = KernelInboxMessage::ConfigureBridge( + sign_kernel_bridge_config( + &ask, + KernelBridgeConfig { + ticketer: "KT1Fq8fPi2NjhWUXtcXBggbL6zFjZctGkmso".to_string(), + }, + ) + .expect("sign bridge"), + ); + let shield = KernelInboxMessage::Shield(KernelShieldReq { + sender: "alice".into(), + fee: 100, + v: 400, + producer_fee: 1, + address: sample_payment_address(), + memo: None, + proof: oversize_kernel_stark_proof(), + client_cm: ZERO, + client_enc: None, + producer_cm: [0; 32], + producer_enc: Some(sample_encrypted_note(0x42)), + }); + let transfer = KernelInboxMessage::Transfer(KernelTransferReq { + root: [1; 32], + nullifiers: vec![[2; 32]], + fee: 100, + cm_1: [4; 32], + cm_2: [5; 32], + cm_3: [6; 32], + enc_1: sample_encrypted_note(0x11), + enc_2: sample_encrypted_note(0x22), + enc_3: sample_encrypted_note(0x33), + proof: oversize_kernel_stark_proof(), + }); + let unshield = KernelInboxMessage::Unshield(KernelUnshieldReq { + root: [1; 32], + nullifiers: vec![[2; 32]], + v_pub: 100, + fee: 100, + recipient: "alice".into(), + cm_change: [3; 32], + enc_change: None, + cm_fee: [4; 32], + enc_fee: sample_encrypted_note(0x11), + proof: oversize_kernel_stark_proof(), + }); + let withdraw = KernelInboxMessage::Withdraw(KernelWithdrawReq { + sender: "alice".into(), + recipient: "tz1target".into(), + amount: 42, + }); + let dal_pointer = KernelInboxMessage::DalPointer(KernelDalPayloadPointer { + kind: KernelDalPayloadKind::Shield, + chunks: vec![KernelDalChunkPointer { + published_level: 100, + slot_index: 0, + payload_len: 4096, + }], + payload_len: 4096, + payload_hash: [0xA5; 32], + }); + + let cases: [(&str, &KernelInboxMessage); 7] = [ + ("ConfigureVerifier", &configure_verifier), + ("ConfigureBridge", &configure_bridge), + ("Shield", &shield), + ("Transfer", &transfer), + ("Unshield", &unshield), + ("Withdraw", &withdraw), + ("DalPointer", &dal_pointer), + ]; + + for (name, message) in cases { + let expected = required_routing(message); + let size = framed_len(message); + match expected { + Routing::FitsL1 => assert!( + size <= L1_INBOX_MESSAGE_LIMIT, + "{}: classified FitsL1 but framed size {} > {}; either the \ + message grew past the L1 limit and needs a DAL route, or \ + the classification in `required_routing` is wrong", + name, + size, + L1_INBOX_MESSAGE_LIMIT, + ), + Routing::RequiresDal => assert!( + size > L1_INBOX_MESSAGE_LIMIT, + "{}: classified RequiresDal but framed size {} <= {}; the \ + message now fits in L1, making the DAL routing for this \ + variant dead code — either downgrade to FitsL1 (and prune \ + the DAL plumbing) or grow the representative instance", + name, + size, + L1_INBOX_MESSAGE_LIMIT, + ), + } + } + } } diff --git a/docs/shadownet_tutorial.md b/docs/shadownet_tutorial.md index 19e375d..064df72 100644 --- a/docs/shadownet_tutorial.md +++ b/docs/shadownet_tutorial.md @@ -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 ``` @@ -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 diff --git a/scripts/octez_rollup_sandbox_dal_smoke.sh b/scripts/octez_rollup_sandbox_dal_smoke.sh index 1876391..a602316 100755 --- a/scripts/octez_rollup_sandbox_dal_smoke.sh +++ b/scripts/octez_rollup_sandbox_dal_smoke.sh @@ -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) @@ -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() { @@ -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}" } @@ -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() { @@ -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 @@ -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}" \ @@ -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}")" @@ -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 @@ -612,23 +667,27 @@ 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" @@ -636,7 +695,11 @@ main() { 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}" diff --git a/tezos/rollup-kernel/README.md b/tezos/rollup-kernel/README.md index a9a9fad..9400f91 100644 --- a/tezos/rollup-kernel/README.md +++ b/tezos/rollup-kernel/README.md @@ -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 `-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 diff --git a/tezos/rollup-kernel/build.rs b/tezos/rollup-kernel/build.rs new file mode 100644 index 0000000..e5458d2 --- /dev/null +++ b/tezos/rollup-kernel/build.rs @@ -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); + } +} diff --git a/tezos/rollup-kernel/src/bin/octez_kernel_message.rs b/tezos/rollup-kernel/src/bin/octez_kernel_message.rs index 4bce217..22162e5 100644 --- a/tezos/rollup-kernel/src/bin/octez_kernel_message.rs +++ b/tezos/rollup-kernel/src/bin/octez_kernel_message.rs @@ -15,7 +15,7 @@ use tzel_core::{ fn usage() -> ! { eprintln!( - "usage:\n octez_kernel_message admin-material\n octez_kernel_message configure-bridge \n octez_kernel_message configure-verifier \n octez_kernel_message dal-pointer ( )+" + "usage:\n octez_kernel_message admin-material\n octez_kernel_message configure-bridge \n octez_kernel_message configure-bridge-payload \n octez_kernel_message configure-verifier \n octez_kernel_message configure-verifier-payload \n octez_kernel_message dal-pointer ( )+" ); std::process::exit(2); } @@ -48,6 +48,14 @@ fn config_admin_ask() -> F { return parse_felt(&hex); } if cfg!(debug_assertions) { + // Public, reproducible dev ask. Signs with a value that any + // release-profile kernel baked without admin material will accept + // via its `debug_assertions` fallback — convenient for tests but a + // footgun in any non-debug deployment. Make the fallback visible. + eprintln!( + "octez_kernel_message: WARNING: TZEL_ROLLUP_CONFIG_ADMIN_ASK_HEX not set, \ + falling back to the public dev ask. This is only safe for local sandbox / tests." + ); return hash(b"tzel-dev-rollup-config-admin"); } panic!("set TZEL_ROLLUP_CONFIG_ADMIN_ASK_HEX to sign config messages"); @@ -58,6 +66,8 @@ fn parse_dal_kind(kind: &str) -> KernelDalPayloadKind { "shield" => KernelDalPayloadKind::Shield, "transfer" => KernelDalPayloadKind::Transfer, "unshield" => KernelDalPayloadKind::Unshield, + "configure_verifier" => KernelDalPayloadKind::ConfigureVerifier, + "configure_bridge" => KernelDalPayloadKind::ConfigureBridge, _ => usage(), } } @@ -113,6 +123,27 @@ fn main() { ), ); } + "configure-bridge-payload" => { + // Emit the raw unframed KernelInboxMessage hex. Same reason as + // configure-verifier-payload: the WOTS-signed message exceeds + // sc_rollup_message_size_limit = 4096 bytes and must therefore + // be chunked and routed via DAL, referenced from L1 via a + // KernelDalPayloadPointer with kind = ConfigureBridge. + let Some(ticketer) = args.next() else { + usage(); + }; + if args.next().is_some() { + usage(); + } + let ask = config_admin_ask(); + let signed = sign_kernel_bridge_config(&ask, KernelBridgeConfig { ticketer }) + .expect("bridge config should sign"); + let payload = encode_kernel_inbox_message( + &KernelInboxMessage::ConfigureBridge(signed), + ) + .expect("kernel message should encode"); + println!("{}", hex_encode(payload)); + } "configure-verifier" => { let Some(rollup_address) = args.next() else { usage(); @@ -151,6 +182,47 @@ fn main() { ), ); } + "configure-verifier-payload" => { + // Emit the raw unframed KernelInboxMessage hex. The signed + // WOTS-authenticated message exceeds the L1 inbox size limit + // (sc_rollup_message_size_limit = 4096), so it cannot be sent + // as a direct external message — it must be chunked and + // published as DAL slot(s), then referenced from L1 via a + // KernelDalPayloadPointer with kind = ConfigureVerifier. + let Some(auth_domain) = args.next() else { + usage(); + }; + let Some(shield) = args.next() else { + usage(); + }; + let Some(transfer) = args.next() else { + usage(); + }; + let Some(unshield) = args.next() else { + usage(); + }; + if args.next().is_some() { + usage(); + } + let ask = config_admin_ask(); + let signed = sign_kernel_verifier_config( + &ask, + KernelVerifierConfig { + auth_domain: parse_felt(&auth_domain), + verified_program_hashes: ProgramHashes { + shield: parse_felt(&shield), + transfer: parse_felt(&transfer), + unshield: parse_felt(&unshield), + }, + }, + ) + .expect("verifier config should sign"); + let payload = encode_kernel_inbox_message( + &KernelInboxMessage::ConfigureVerifier(signed), + ) + .expect("kernel message should encode"); + println!("{}", hex_encode(payload)); + } "dal-pointer" => { let Some(rollup_address) = args.next() else { usage(); diff --git a/tezos/rollup-kernel/src/bin/verified_bridge_fixture_message.rs b/tezos/rollup-kernel/src/bin/verified_bridge_fixture_message.rs index 70f5ddb..ba00829 100644 --- a/tezos/rollup-kernel/src/bin/verified_bridge_fixture_message.rs +++ b/tezos/rollup-kernel/src/bin/verified_bridge_fixture_message.rs @@ -38,7 +38,16 @@ mod with_verifier { bridge_ticketer: &'a str, withdrawal_recipient: &'a str, shield_sender: &'a str, - shield_amount: u64, + // Total mutez the sandbox must deposit into the bridge so that the + // fixture shield applies cleanly. `apply_shield` debits + // `v + fee + producer_fee` from the sender's public balance (see + // `core/src/lib.rs::apply_shield`, added by the "Add burned rollup + // fees and DAL producer note outputs" commit). Exposing the sum + // rather than just `v` keeps the sandbox in sync with the kernel + // debit semantics; exposing only `v` (the historical behaviour) + // leaves the balance short by `fee + producer_fee` and the shield + // fails with "insufficient balance". + shield_bridge_deposit: u64, } fn usage() -> ! { @@ -151,7 +160,9 @@ mod with_verifier { bridge_ticketer: &fixture.bridge_ticketer, withdrawal_recipient: &fixture.withdrawal_recipient, shield_sender: &fixture.shield.sender, - shield_amount: fixture.shield.v, + shield_bridge_deposit: fixture.shield.v + + fixture.shield.fee + + fixture.shield.producer_fee, }; println!( "{}", diff --git a/tezos/rollup-kernel/src/lib.rs b/tezos/rollup-kernel/src/lib.rs index a9c6e3e..60b6089 100644 --- a/tezos/rollup-kernel/src/lib.rs +++ b/tezos/rollup-kernel/src/lib.rs @@ -779,9 +779,39 @@ fn dal_payload_kind_name(kind: &KernelDalPayloadKind) -> &'static str { KernelDalPayloadKind::Shield => "shield", KernelDalPayloadKind::Transfer => "transfer", KernelDalPayloadKind::Unshield => "unshield", - } -} - + KernelDalPayloadKind::ConfigureVerifier => "configure_verifier", + KernelDalPayloadKind::ConfigureBridge => "configure_bridge", + } +} + +/// Fetch and decode a kernel inbox message that was too large to fit in +/// the L1 inbox and was therefore published via DAL. +/// +/// Invariant enforced here: the `pointer.kind` must match the decoded +/// message's variant. Adding a new oversized message type requires: +/// 1. A new `KernelDalPayloadKind` variant in `core/src/kernel_wire.rs`. +/// 2. A matching arm below. +/// 3. (If the message is relayed through tzel-operator for user ops.) +/// A `RollupSubmissionKind` variant and the corresponding +/// `dal_pointer_from_submission` mapping. Admin-only kinds stay out +/// of the operator interface by design. +/// 4. A size-sentinel test in `core/src/kernel_wire.rs::tests` to catch +/// future encoding changes. +/// +/// Authentication model: +/// - The `kind`-vs-content consistency check is a **defense-in-depth** +/// control: a malicious or buggy publisher could otherwise submit a +/// DAL pointer declaring `kind=Shield` while the actual payload is a +/// `ConfigureBridge`, confusing any audit tooling that relies on the +/// declared kind. Rejecting the inconsistency forces publishers to +/// label submissions honestly. +/// - The **authenticity** of the decoded message comes from the +/// signature / STARK proof inside the payload itself, verified +/// against the admin leaves baked into the WASM at build time +/// (config messages) or against the compiled verifier config +/// (shield / transfer / unshield). DAL is a public bulletin board +/// — anyone can publish — so transport-level authentication is +/// neither available nor relied upon. fn fetch_kernel_message_from_dal( host: &H, pointer: &KernelDalPayloadPointer, @@ -899,19 +929,34 @@ fn fetch_kernel_message_from_dal( } let message = decode_kernel_inbox_message(&payload)?; - match (&pointer.kind, &message) { - (KernelDalPayloadKind::Shield, KernelInboxMessage::Shield(_)) - | (KernelDalPayloadKind::Transfer, KernelInboxMessage::Transfer(_)) - | (KernelDalPayloadKind::Unshield, KernelInboxMessage::Unshield(_)) => Ok(message), - (_, KernelInboxMessage::DalPointer(_)) => { - Err("nested DAL pointer messages are not supported".into()) - } - _ => Err(format!( + // Nested DAL pointers are never legal, regardless of the declared kind. + if matches!(message, KernelInboxMessage::DalPointer(_)) { + return Err("nested DAL pointer messages are not supported".into()); + } + // Match on `pointer.kind` with no catch-all so that adding a new variant + // to `KernelDalPayloadKind` is a compile error until this dispatcher is + // updated. The boolean result below is the runtime guard: if the payload + // decoded to an unrelated variant, we fail loudly rather than applying + // the wrong kernel message. + let kind_name = dal_payload_kind_name(&pointer.kind); + let kind_matches_content = match pointer.kind { + KernelDalPayloadKind::Shield => matches!(message, KernelInboxMessage::Shield(_)), + KernelDalPayloadKind::Transfer => matches!(message, KernelInboxMessage::Transfer(_)), + KernelDalPayloadKind::Unshield => matches!(message, KernelInboxMessage::Unshield(_)), + KernelDalPayloadKind::ConfigureVerifier => { + matches!(message, KernelInboxMessage::ConfigureVerifier(_)) + } + KernelDalPayloadKind::ConfigureBridge => { + matches!(message, KernelInboxMessage::ConfigureBridge(_)) + } + }; + if !kind_matches_content { + return Err(format!( "DAL payload kind mismatch: pointer declared {}, decoded {:?}", - dal_payload_kind_name(&pointer.kind), - message - )), + kind_name, message + )); } + Ok(message) } fn apply_kernel_message(