Operator TX — 实现

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 将 Oracle 价格注入方式从 bootloader 内存 slot 改为 operator 合成 L2 交易注入,完成 Oracle E2E 闭环。

Architecture: State Keeper 在每个 batch 开始时构造一笔 operator → OracleHub 的合成 L2Tx(仿照 ProtocolUpgradeTx 模式),替代 bootloader memory slot 方式。完全移除旧的 bootloader Oracle 代码,OracleHub 权限从 onlyCallFromBootloader 改为 onlyOperator

Tech Stack: Rust (era-core 200+ crates), Solidity (bootloader.yul + OracleHub.sol), Foundry (合约测试)

Design Doc: docs/plans/2026-03-04-oracle-operator-tx-design.md


Task 1: oracle_tx.rs 交易构造 + 单元测试

Files:

  • Create: era-core/core/node/state_keeper/src/oracle_tx.rs

  • Modify: era-core/core/node/state_keeper/src/lib.rs:17 (add module)

Step 1: Create oracle_tx.rs with build function + tests

Create era-core/core/node/state_keeper/src/oracle_tx.rs:

//! Oracle transaction builder — constructs synthetic L2Tx for price updates.
//!
//! The State Keeper calls `build_oracle_update_tx()` at the start of each batch
//! to inject Oracle price data as the first user-space transaction, immediately
//! after any protocol upgrade tx.

use zksync_contracts::ORACLE_HUB_ADDRESS;
use zksync_types::{
    fee::Fee, l2::L2Tx, Address, Nonce, L1BatchNumber, U256,
    transaction_request::PaymasterParams,
};

/// Gas limit for the Oracle update transaction.
/// Generous limit — operator doesn't actually pay gas.
const ORACLE_TX_GAS_LIMIT: u64 = 5_000_000;

/// Build an Oracle price update transaction.
///
/// Returns a `Transaction` that calls `OracleHub.batchUpdatePrices()` with the
/// pre-encoded ABI calldata from the Oracle service.
///
/// The transaction uses L2Tx type (EIP-712) with:
/// - sender: operator (fee_account)
/// - target: ORACLE_HUB_ADDRESS (0x8016)
/// - calldata: pre-encoded batchUpdatePrices(bytes32[],uint128[],uint64[],uint8[])
/// - gas_limit: 5M (operator doesn't pay)
/// - nonce: Nonce(0) — operator tx, not validated by mempool
pub fn build_oracle_update_tx(
    operator_address: Address,
    oracle_calldata: Vec<u8>,
) -> zksync_types::Transaction {
    let l2_tx = L2Tx::new(
        Some(ORACLE_HUB_ADDRESS),          // to: OracleHub (0x8016)
        oracle_calldata,                     // calldata: batchUpdatePrices(...)
        Nonce(0),                            // nonce: operator tx, not validated
        Fee {
            gas_limit: U256::from(ORACLE_TX_GAS_LIMIT),
            max_fee_per_gas: U256::from(0u64),
            max_priority_fee_per_gas: U256::from(0u64),
            gas_per_pubdata_limit: U256::from(800u64),
        },
        operator_address,                    // from: operator
        U256::zero(),                        // value: 0
        vec![],                              // factory_deps: none
        PaymasterParams::default(),          // no paymaster
    );

    l2_tx.into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use zksync_types::ExecuteTransactionCommon;

    #[test]
    fn test_build_oracle_tx_correct_target() {
        let operator = Address::repeat_byte(0xAA);
        let calldata = vec![0x01, 0x02, 0x03, 0x04];

        let tx = build_oracle_update_tx(operator, calldata);

        assert_eq!(
            tx.execute.contract_address,
            Some(ORACLE_HUB_ADDRESS),
            "Oracle tx must target OracleHub (0x8016)"
        );
    }

    #[test]
    fn test_build_oracle_tx_correct_sender() {
        let operator = Address::repeat_byte(0xBB);
        let calldata = vec![0xDE, 0xAD];

        let tx = build_oracle_update_tx(operator, calldata.clone());

        match &tx.common_data {
            ExecuteTransactionCommon::L2(data) => {
                assert_eq!(data.initiator_address, operator);
            }
            _ => panic!("Oracle tx must be L2 type"),
        }
    }

    #[test]
    fn test_build_oracle_tx_correct_calldata() {
        let operator = Address::repeat_byte(0xCC);
        let calldata = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE];

        let tx = build_oracle_update_tx(operator, calldata.clone());

        assert_eq!(tx.execute.calldata, calldata);
    }

    #[test]
    fn test_build_oracle_tx_zero_value() {
        let operator = Address::repeat_byte(0xDD);
        let calldata = vec![0x01];

        let tx = build_oracle_update_tx(operator, calldata);

        assert_eq!(tx.execute.value, U256::zero());
    }

    #[test]
    fn test_build_oracle_tx_gas_limit() {
        let operator = Address::repeat_byte(0xEE);
        let calldata = vec![0x01];

        let tx = build_oracle_update_tx(operator, calldata);

        match &tx.common_data {
            ExecuteTransactionCommon::L2(data) => {
                assert_eq!(data.fee.gas_limit, U256::from(ORACLE_TX_GAS_LIMIT));
            }
            _ => panic!("Oracle tx must be L2 type"),
        }
    }

    #[test]
    fn test_build_oracle_tx_large_calldata() {
        let operator = Address::repeat_byte(0xFF);
        // 100 pairs × ~128 bytes = ~12800 bytes — far exceeds old 768 byte limit
        let calldata = vec![0x42; 12800];

        let tx = build_oracle_update_tx(operator, calldata.clone());

        assert_eq!(tx.execute.calldata.len(), 12800);
        assert_eq!(tx.execute.calldata, calldata);
    }
}

Step 2: Register module in lib.rs

Modify era-core/core/node/state_keeper/src/lib.rs — add pub mod oracle_tx; after line 16 (pub mod io;):

Step 3: Verify compilation

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo check -p zksync_state_keeper 2>&1 | tail -5 Expected: compilation succeeds

Step 4: Run tests

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo test -p zksync_state_keeper oracle_tx -- --nocapture 2>&1 | tail -20 Expected: 6 tests pass

Step 5: Commit


Task 2: MempoolIO 接入 SharedOracleService

Files:

  • Modify: era-core/core/node/state_keeper/src/io/mempool.rs:55-75 (add field)

  • Modify: era-core/core/node/state_keeper/src/io/mempool.rs:499-532 (constructor)

  • Modify: era-core/core/node/state_keeper/src/io/mempool.rs:580 (unsealed batch)

  • Modify: era-core/core/node/state_keeper/src/io/mempool.rs:710 (new batch)

Step 1: Add SharedOracleService import and field to MempoolIO

Add import at top of mempool.rs (after existing imports):

Add field to MempoolIO struct (after settlement_layer at line 74):

Step 2: Update constructor to accept oracle_service

Modify MempoolIO::new() at line 499:

Step 3: Wire oracle_calldata into L1BatchParams construction

Replace oracle_calldata: None at line 580 (unsealed batch) with:

Replace oracle_calldata: None at line 710 (new batch) with:

Step 4: Add helper method to MempoolIO

Add to the impl MempoolIO block at line 734:

Step 5: Add baby_oracle_wiring dependency to state_keeper Cargo.toml

Check if baby_oracle_wiring is already a dependency:

Run: grep baby_oracle_wiring era-core/core/node/state_keeper/Cargo.toml

If not present, add to [dependencies] section:

Note: If the crate path differs, search for existing usages: Run: grep -r baby_oracle_wiring era-core/core/ --include="Cargo.toml" | head -5

Step 6: Find and update all callers of MempoolIO::new()

Search for call sites:

Run: cd /Users/judybaby/CodeBase/github/Layer2 && grep -rn "MempoolIO::new(" era-core/ --include="*.rs"

For each call site, add None (or the actual SharedOracleService) as the last argument. The primary caller is in the node wiring layer — it will need the actual service. Test callers should pass None.

Step 7: Verify compilation

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo check -p zksync_state_keeper 2>&1 | tail -10 Expected: compilation succeeds (may need to fix callers first)

Step 8: Commit


Task 3: keeper.rs Oracle tx 注入

Files:

  • Modify: era-core/core/node/state_keeper/src/keeper.rs:660-710 (add process_oracle_tx)

  • Modify: era-core/core/node/state_keeper/src/keeper.rs:909-949 (inject in process_block)

  • Modify: era-core/core/node/state_keeper/src/io/mod.rs:189 (trait extension)

Step 1: Add get_oracle_tx_calldata to StateKeeperIO trait

Add to the StateKeeperIO trait in io/mod.rs after load_batch_state_hash() (line 254):

Step 2: Implement in MempoolIO

Add to impl StateKeeperIO for MempoolIO block (after any existing method, around line 495):

Step 3: Add process_oracle_tx method to StateKeeperInner

Add in keeper.rs after process_upgrade_tx() (after line 710):

Step 4: Inject Oracle tx in process_block()

Modify process_block() in keeper.rs around line 921. The current code:

Change to:

IMPORTANT: The original else branch (lines 926-948) handles the case where there's no protocol_upgrade_tx. After restructuring, we need to keep that logic but adjust the flow. The key is:

  1. Protocol upgrade tx (if any)

  2. Oracle tx (if any)

  3. L2 block params for non-first blocks

  4. Fictive block check

  5. User tx loop

The exact restructuring needs care to preserve the original control flow. Read the full process_block() method carefully and ensure:

  • set_l2_block_params is still called for non-first blocks

  • next_block_should_be_fictive logic is preserved

  • The oracle tx runs only on first call per batch

Step 5: Verify compilation

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo check -p zksync_state_keeper 2>&1 | tail -10 Expected: compilation succeeds

Step 6: Commit


Task 4: 移除 Rust 旧代码 (oracle_calldata 通道)

Files to modify (remove oracle_calldata):

#
File
Line(s)
Change

1

era-core/core/lib/vm_interface/src/types/inputs/l1_batch_env.rs:27-31

Remove oracle_calldata field

2

era-core/core/lib/multivm/src/versions/vm_latest/bootloader/init.rs:15-19,52-91

Remove ORACLE constants + append_oracle_calldata()

3

era-core/core/node/state_keeper/src/io/mod.rs:130-131

Remove oracle_calldata from L1BatchParams

4

era-core/core/node/state_keeper/src/io/mod.rs:167-168

Remove l1_batch_env.oracle_calldata = ...

5

era-core/core/node/state_keeper/src/io/mempool.rs:580

Remove oracle_calldata: self.get_oracle_calldata() (set in Task 2)

6

era-core/core/node/state_keeper/src/io/mempool.rs:710

Same as above

7

era-core/core/lib/vm_executor/src/storage.rs:97-98

Remove oracle_calldata: None

8

era-core/core/lib/vm_executor/src/testonly.rs:48

Remove oracle_calldata: None

9

era-core/core/lib/vm_executor/src/oneshot/block.rs:278

Remove oracle_calldata: None

10

era-core/core/node/consensus/src/testonly.rs:270

Remove oracle_calldata: None

11

era-core/core/node/test_utils/src/lib.rs:66

Remove oracle_calldata: None

12

era-core/core/node/node_sync/src/external_io.rs:545

Remove oracle_calldata: None

13

era-core/core/node/node_sync/src/tests.rs:47

Remove oracle_calldata: None

14

era-core/core/node/node_sync/src/fetcher.rs:189

Remove oracle_calldata: None

15

era-core/core/node/node_sync/src/sync_action.rs:204

Remove oracle_calldata: None

16

era-core/core/node/vm_runner/src/tests/output_handler.rs:57

Remove oracle_calldata: None

17

era-core/core/node/state_keeper/src/testonly/test_batch_executor.rs:822

Remove oracle_calldata: None

18

era-core/core/tests/vm-benchmark/src/vm.rs:249

Remove oracle_calldata: None

19

era-core/core/bin/system-constants-generator/src/utils.rs:195

Remove oracle_calldata: None

20

era-core/core/lib/tee_verifier/src/lib.rs:347

Remove oracle_calldata: None

21

era-core/core/lib/multivm/src/versions/testonly/mod.rs:199

Remove oracle_calldata: None

Step 1: Remove oracle_calldata field from L1BatchEnv

In era-core/core/lib/vm_interface/src/types/inputs/l1_batch_env.rs:

  • Delete lines 27-31 (the oracle_calldata field + comments + serde attribute)

Step 2: Remove oracle_calldata from L1BatchParams and into_init_params()

In era-core/core/node/state_keeper/src/io/mod.rs:

  • Delete line 130-131 (pub oracle_calldata: Option<Vec<u8>> + comment)

  • Delete lines 167-168 (l1_batch_env.oracle_calldata = self.oracle_calldata; + comment)

Step 3: Remove oracle_calldata from MempoolIO batch construction

In mempool.rs:

  • Delete line 580 (oracle_calldata: ...)

  • Delete line 710 (oracle_calldata: ...)

  • Delete the get_oracle_calldata() helper method added in Task 2 (no longer needed)

  • Delete the oracle_service field from MempoolIO struct (no longer needed for batch params)

  • BUT KEEP the oracle_service field — it's still needed for get_oracle_tx_calldata() on the trait!

Wait — actually, with the new design, the oracle_calldata is no longer passed via L1BatchParams/L1BatchEnv. Instead, it's obtained via StateKeeperIO::get_oracle_tx_calldata() in keeper.rs. So:

  • Remove oracle_calldata from L1BatchParams (stop passing it through the batch env pipe)

  • Keep oracle_service field in MempoolIO (used by get_oracle_tx_calldata())

  • Remove get_oracle_calldata() private helper if it was only used for batch params

Step 4: Remove bootloader memory slot code

In era-core/core/lib/multivm/src/versions/vm_latest/bootloader/init.rs:

  • Delete lines 15-19 (ORACLE_CALLDATA_BEGIN_SLOT + ORACLE_CALLDATA_MAX_SLOTS constants)

  • Delete line 52-53 (Self::append_oracle_calldata(...) call)

  • Delete lines 58-91 (entire append_oracle_calldata() method)

Step 5: Remove oracle_calldata: None from all 14 other files

For each file listed in items 7-21 above, delete the oracle_calldata: None, line.

Step 6: Verify compilation

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo check 2>&1 | tail -10 Expected: compilation succeeds across all 200+ crates

Step 7: Verify no remaining oracle_calldata references

Run: grep -rn "oracle_calldata" era-core/ --include="*.rs" Expected: no results (all references removed)

Step 8: Run existing tests

Run: cd /Users/judybaby/CodeBase/github/Layer2/era-core/core && cargo test -p zksync_state_keeper -- --nocapture 2>&1 | tail -20 Expected: all tests pass

Step 9: Commit


Task 5: 移除 bootloader.yul Oracle 代码

Files:

  • Modify: era-contracts-l1/system-contracts/bootloader/bootloader.yul

Step 1: Remove Oracle slot functions

Delete lines 131-146 (ORACLE_CALLDATA_BEGIN_SLOT, ORACLE_CALLDATA_BEGIN_BYTE, ORACLE_CALLDATA_MAX_SLOTS):

Step 2: Restore SCRATCH_SPACE_BEGIN_SLOT

Change lines 148-150 from:

To:

Step 3: Remove ORACLE_HUB_ADDR function

Delete lines 684-687:

Step 4: Remove updateOraclePrices function

Delete lines 3231-3257 (the entire updateOraclePrices() function).

Step 5: Remove updateOraclePrices() call sites

Delete lines 4555-4556 (proved batch flow):

Delete lines 4572-4573 (playground batch flow):

Step 6: Verify bootloader compiles

If this fails, try:

Expected: bootloader compiles with no errors

Step 7: Verify system contracts compile

Expected: compilation succeeds

Step 8: Commit


Task 6: OracleHub.sol onlyOperator + Foundry 测试

Files:

  • Modify: era-contracts-l1/system-contracts/contracts/OracleHub.sol

  • Modify: era-contracts-l1/system-contracts/contracts/interfaces/IOracleHub.sol

  • Modify: contracts/test/OracleHub.t.sol (add operator permission tests)

Step 1: Add operator storage + modifier to OracleHub.sol

In OracleHub.sol, add after line 44 (uint256 private constant BPS_DENOMINATOR = 10_000;):

Step 2: Change batchUpdatePrices modifier

In OracleHub.sol line 80, change:

To:

Step 3: Add setOperator function

Add after setConfig() (after line 156):

Step 4: Update initialize() to set initial operator

In initialize() (line 55), change from onlyCallFromBootloader to accept an operator param:

Actually, initialize() should stay onlyCallFromBootloader since it's called during genesis. But add operator initialization inside it:

Add before the closing } of initialize() (before line 69):

Step 5: Add OperatorUpdated event to IOracleHub.sol

In IOracleHub.sol, add after line 23 (ConfigUpdated event):

Add to the interface functions (after setConfig at line 41):

Step 6: Update comment in OracleHub.sol

Change the contract-level comment (lines 12-19) to reflect new design:

  • "Price data is injected by the operator via synthetic L2 transaction at the start of each L1 Batch"

  • Remove reference to "bootloader"

Step 7: Add Foundry tests

In contracts/test/OracleHub.t.sol, add operator permission tests:

Note: The exact test implementation depends on the existing test file structure. Read contracts/test/OracleHub.t.sol first to understand the existing test setup.

Step 8: Run Foundry tests

Expected: all tests pass (existing + new operator tests)

Step 9: Compile system contracts

Expected: compilation succeeds

Step 10: Commit


Task 7: 全量回归 + dev-log

Files:

  • Verify: all era-core crates compile

  • Verify: all Foundry tests pass

  • Verify: bootloader compiles

  • Modify: docs/dev-log.md (add Phase 2.6 entry)

Step 1: era-core full cargo check

Expected: all crates compile (200+)

Step 2: Run oracle_tx tests

Expected: 6 tests pass

Step 3: Run oracle-wiring tests

Expected: 38 tests pass

Step 4: Run Foundry tests

Expected: 241+ tests pass

Step 5: Verify bootloader compilation

Expected: bootloader preprocesses successfully

Step 6: Verify no remaining oracle_calldata references in Rust

Expected: 0

Step 7: Update dev-log

Add Phase 2.6 entry to docs/dev-log.md:

Step 8: Commit


Summary

Task
Description
New Tests
Files Changed

1

oracle_tx.rs 交易构造

6

2 (new + lib.rs)

2

MempoolIO 接入 SharedOracleService

0

2-3 (mempool.rs + Cargo.toml + callers)

3

keeper.rs Oracle tx 注入

0

2 (keeper.rs + io/mod.rs)

4

移除 Rust 旧代码

0

19 files

5

移除 bootloader.yul Oracle 代码

0

1 (bootloader.yul)

6

OracleHub.sol onlyOperator

3

3 (OracleHub.sol + IOracleHub.sol + test)

7

全量回归 + dev-log

0

1 (dev-log.md)

Total: ~9 new tests, ~28 files modified

Last updated