From ab09dbfd9553a7ad48f9c134ec5e808c36512c74 Mon Sep 17 00:00:00 2001 From: Romain Ruetschi Date: Wed, 7 Oct 2026 14:24:36 +0200 Subject: [PATCH 1/2] fix(pq-precompile): reserve base gas before decoding input (#486) Closes: #487 ## Summary Charge the base gas and the message gas before the PQ precompile copies its three `bytes` arguments out of the input. The work that a call does is then limited by the gas that it pays. Co-authored-by: Zhiyu Zhang Co-authored-by: Milap Sheth --- Cargo.lock | 1 + crates/pq-precompile/Cargo.toml | 3 + crates/pq-precompile/src/lib.rs | 309 ++++++++++++++++++++++++++++++-- 3 files changed, 299 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 560acdd..dd4c481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1999,6 +1999,7 @@ version = "0.0.1" dependencies = [ "alloy-primitives", "alloy-sol-types", + "proptest", "revm", "revm-interpreter", "slh-dsa", diff --git a/crates/pq-precompile/Cargo.toml b/crates/pq-precompile/Cargo.toml index 2bf792c..55b3800 100644 --- a/crates/pq-precompile/Cargo.toml +++ b/crates/pq-precompile/Cargo.toml @@ -16,5 +16,8 @@ revm.workspace = true revm-interpreter.workspace = true slh-dsa.workspace = true +[dev-dependencies] +proptest.workspace = true + [lints] workspace = true diff --git a/crates/pq-precompile/src/lib.rs b/crates/pq-precompile/src/lib.rs index c6d5dfa..025385a 100644 --- a/crates/pq-precompile/src/lib.rs +++ b/crates/pq-precompile/src/lib.rs @@ -15,7 +15,7 @@ // limitations under the License. use alloy_primitives::{address, Address, Bytes}; -use alloy_sol_types::{sol, SolValue}; +use alloy_sol_types::{abi, sol, SolCall, SolType, SolValue}; use revm::precompile::{PrecompileError, PrecompileHalt, PrecompileOutput}; use revm_interpreter::gas::KECCAK256WORD; use revm_interpreter::Gas; @@ -80,17 +80,35 @@ fn early_revert( )) } +/// The `(bytes, bytes, bytes)` parameter tuple of [`IPQ::verifySlhDsaSha2128sCall`]. +type CallParams<'a> = ::Parameters<'a>; + +/// Token form of [`CallParams`]: three `PackedSeqToken`s that borrow from the input. +type CallTokens<'a> = ::Token<'a>; + +/// Gas for hashing `msg_len` message bytes, one KECCAK256-rate charge per 32-byte word. +/// +/// `GAS_PER_MSG_WORD` (6) < 32, so `div_ceil(32) * GAS_PER_MSG_WORD` cannot exceed `u64::MAX`. +#[allow(clippy::arithmetic_side_effects)] +fn msg_word_gas(msg_len: usize) -> u64 { + (msg_len as u64).div_ceil(32) * GAS_PER_MSG_WORD +} + /// Executes the PQ (SLH-DSA-SHA2-128s) precompile. /// /// Early-path failures (short input, wrong selector, ABI decode error) charge a 200-gas /// penalty and return OOG if the caller has insufficient gas — preventing free probing. +/// +/// Base and message gas are reserved before the byte arguments are copied out of the +/// input, so the work a call performs is bounded by the gas it pays. Decoding is split to +/// allow this: `abi_decode_raw_validate` is tokenize, then `type_check`, then `detokenize`, +/// and only `detokenize` copies. The first two steps run before the charges; the copy runs +/// after them. pub fn run_pq_precompile( gas: u64, data: &[u8], reservoir: u64, ) -> Result { - use alloy_sol_types::SolCall; - let mut gas_counter = Gas::new(gas); if data.len() < 4 { @@ -102,8 +120,12 @@ pub fn run_pq_precompile( return early_revert(&mut gas_counter, reservoir, "Invalid selector"); } - let args = match IPQ::verifySlhDsaSha2128sCall::abi_decode_raw_validate(&data[4..]) { - Ok(args) => args, + // Tokenize and type-check without copying: reads the offset and length words, bounds-checks + // them against the body, and yields borrowed slices. O(1) regardless of input size. + let tokens = match abi::decode_sequence::>(&data[4..]) + .and_then(|tokens| CallParams::<'_>::type_check(&tokens).map(|()| tokens)) + { + Ok(tokens) => tokens, Err(_) => return early_revert(&mut gas_counter, reservoir, "Execution reverted"), }; @@ -111,13 +133,13 @@ pub fn run_pq_precompile( return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); } - // GAS_PER_MSG_WORD (6) < 32, so the product cannot exceed u64::MAX - #[allow(clippy::arithmetic_side_effects)] - let msg_word_gas = (args.message.len() as u64).div_ceil(32) * GAS_PER_MSG_WORD; - if !gas_counter.record_regular_cost(msg_word_gas) { + if !gas_counter.record_regular_cost(msg_word_gas(tokens.1.as_slice().len())) { return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); } + // Paid for: copy the byte arguments out of the input. + let args = IPQ::verifySlhDsaSha2128sCall::new(CallParams::<'_>::detokenize(tokens)); + if args.vk.len() != VK_LEN { return Ok(PrecompileOutput::revert( gas_counter.used(), @@ -167,15 +189,20 @@ pub fn run_pq_precompile( #[cfg(test)] mod tests { use super::{ - run_pq_precompile, EARLY_REVERT_GAS, GAS_PER_MSG_WORD, IPQ, SIG_LEN, VERIFY_BASE_GAS, - VK_LEN, + early_revert, revert_message_to_bytes, run_pq_precompile, EARLY_REVERT_GAS, + GAS_PER_MSG_WORD, IPQ, SIG_LEN, VERIFY_BASE_GAS, VK_LEN, }; + use alloy_primitives::U256; use alloy_sol_types::{SolCall, SolValue}; - use revm::precompile::PrecompileOutput; + use proptest::prelude::*; + use revm::precompile::{PrecompileError, PrecompileHalt, PrecompileOutput}; + use revm_interpreter::Gas; use slh_dsa::{ - signature::{Keypair, Signer}, - Sha2_128s, SigningKey, + signature::{Keypair, Signer, Verifier}, + Sha2_128s, Signature, SigningKey, VerifyingKey as SlhDsaVerifyingKey, }; + use std::sync::atomic::Ordering; + use std::sync::LazyLock; fn make_keypair() -> SigningKey { SigningKey::::slh_keygen_internal(&[1u8; 16], &[2u8; 16], &[3u8; 16]) @@ -292,4 +319,258 @@ mod tests { let expected = VERIFY_BASE_GAS + 2 * GAS_PER_MSG_WORD; assert_eq!(output.gas_used, expected); } + + // --- Gas is reserved before the byte arguments are copied --- + + /// Calldata whose three dynamic offsets alias one `shared_len`-byte array. The ABI decoder + /// accepts it; `vk` and `sig` then fail their length checks, but only after base and + /// message gas have been charged. + fn aliased_calldata(shared_len: usize) -> Vec { + let mut calldata = IPQ::verifySlhDsaSha2128sCall::SELECTOR.to_vec(); + for _ in 0..3 { + calldata.extend_from_slice(&U256::from(0x60).to_be_bytes::<32>()); + } + calldata.extend_from_slice(&U256::from(shared_len).to_be_bytes::<32>()); + let padded_len = shared_len.div_ceil(32).saturating_mul(32); + calldata.resize(calldata.len().saturating_add(padded_len), 0); + calldata + } + + #[test] + fn zero_gas_aliased_input_halts_out_of_gas() { + let calldata = aliased_calldata(1 << 20); + let output = run_pq_precompile(0, &calldata, 0).expect("Ok(halt)"); + assert!(output.is_halt()); + assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + } + + #[test] + fn underfunded_aliased_input_below_base_gas_halts_out_of_gas() { + let calldata = aliased_calldata(1 << 20); + let output = + run_pq_precompile(VERIFY_BASE_GAS.saturating_sub(1), &calldata, 0).expect("Ok(halt)"); + assert!(output.is_halt()); + assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + } + + /// An underfunded call must not copy the byte arguments. 64 zero-gas calls with a 1 MiB + /// aliased body would copy 3 MiB each (192 MiB total) if the arguments were copied before + /// gas is reserved; reserving first allocates nothing for the arguments. Other tests run + /// concurrently, so the bound is loose (16 MiB) but still an order of magnitude below the + /// copying figure. + #[test] + fn underfunded_aliased_input_is_not_copied() { + const ITERATIONS: usize = 64; + const BODY_LEN: usize = 1 << 20; + const MAX_ALLOCATED_BYTES: usize = 16 << 20; + + let calldata = aliased_calldata(BODY_LEN); + let before = alloc_counter::ALLOCATED_BYTES.load(Ordering::Relaxed); + for _ in 0..ITERATIONS { + let output = run_pq_precompile(0, &calldata, 0).expect("Ok(halt)"); + assert!(output.is_halt()); + } + let allocated = alloc_counter::ALLOCATED_BYTES + .load(Ordering::Relaxed) + .saturating_sub(before); + assert!( + allocated < MAX_ALLOCATED_BYTES, + "underfunded calls allocated {allocated} bytes; the byte arguments are being copied \ + before gas is reserved" + ); + } + + /// Global allocator that counts requested bytes. Only compiled into this crate's test + /// binary; every operation is delegated to `System` unchanged. + mod alloc_counter { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + pub(super) static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0); + + struct CountingAllocator; + + // SAFETY: every call is forwarded to `System` with the caller's arguments unchanged; + // the counter is a relaxed atomic and never touches the allocation itself. + unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed); + // SAFETY: same contract as this function's caller. + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: same contract as this function's caller. + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOCATED_BYTES.fetch_add(new_size, Ordering::Relaxed); + // SAFETY: same contract as this function's caller. + unsafe { System.realloc(ptr, layout, new_size) } + } + } + + #[global_allocator] + static GLOBAL: CountingAllocator = CountingAllocator; + } + + /// Reference implementation that decodes the full input before charging gas. The + /// differential test checks that `run_pq_precompile` returns the same output for every + /// `(gas, input, reservoir)`, so reserving gas first changes no observable result. + #[allow(clippy::arithmetic_side_effects)] + fn legacy_run_pq_precompile( + gas: u64, + data: &[u8], + reservoir: u64, + ) -> Result { + let mut gas_counter = Gas::new(gas); + + if data.len() < 4 { + return early_revert(&mut gas_counter, reservoir, "Input too short"); + } + + let selector = [data[0], data[1], data[2], data[3]]; + if selector != IPQ::verifySlhDsaSha2128sCall::SELECTOR { + return early_revert(&mut gas_counter, reservoir, "Invalid selector"); + } + + let args = match IPQ::verifySlhDsaSha2128sCall::abi_decode_raw_validate(&data[4..]) { + Ok(args) => args, + Err(_) => return early_revert(&mut gas_counter, reservoir, "Execution reverted"), + }; + + if !gas_counter.record_regular_cost(VERIFY_BASE_GAS) { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); + } + + let msg_word_gas = (args.message.len() as u64).div_ceil(32) * GAS_PER_MSG_WORD; + if !gas_counter.record_regular_cost(msg_word_gas) { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); + } + + if args.vk.len() != VK_LEN { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Invalid verifying key length"), + reservoir, + )); + } + if args.sig.len() != SIG_LEN { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Invalid signature length"), + reservoir, + )); + } + + let verifying_key = match SlhDsaVerifyingKey::::try_from(args.vk.as_ref()) { + Ok(vk) => vk, + Err(_) => { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Failed to parse verifying key"), + reservoir, + )); + } + }; + let signature = match Signature::::try_from(args.sig.as_ref()) { + Ok(sig) => sig, + Err(_) => { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Failed to parse signature"), + reservoir, + )); + } + }; + + let is_valid = verifying_key + .verify(args.message.as_ref(), &signature) + .is_ok(); + Ok(PrecompileOutput::new( + gas_counter.used(), + is_valid.abi_encode().into(), + reservoir, + )) + } + + /// A valid signature over a fixed message, signed once (SLH-DSA signing is slow). + static VALID_CALLDATA: LazyLock> = LazyLock::new(|| { + let sk = make_keypair(); + let msg = b"differential vector"; + let sig = sk.sign(msg); + encode_call(&sk.verifying_key().to_bytes(), msg, &sig.to_bytes()) + }); + + /// Canonical encodings with the exact `vk`/`sig` sizes over-represented so the length + /// checks and the verify path are both reached. + fn arb_canonical_body() -> impl Strategy> { + let vk_len = prop_oneof![3 => Just(VK_LEN), 1 => 0usize..=64]; + let msg_len = prop_oneof![1 => Just(0usize), 3 => 0usize..=4096]; + let sig_len = prop_oneof![3 => Just(SIG_LEN), 1 => 0usize..=8192]; + (vk_len, msg_len, sig_len).prop_map(|(vk, msg, sig)| { + encode_call(&vec![0x11; vk], &vec![0x22; msg], &vec![0x33; sig]) + }) + } + + /// Every input shape the decoder and gas checks branch on: canonical, a real valid + /// signature, aliased offsets, truncated bodies, corrupted offset words, random bodies + /// behind the right selector, and fully random bytes. + fn arb_input() -> impl Strategy> { + let selector = IPQ::verifySlhDsaSha2128sCall::SELECTOR; + prop_oneof![ + 3 => arb_canonical_body(), + 1 => Just(VALID_CALLDATA.clone()), + 2 => (0usize..=4096).prop_map(aliased_calldata), + 2 => (arb_canonical_body(), 0usize..=256).prop_map(|(mut body, cut)| { + body.truncate(body.len().saturating_sub(cut)); + body + }), + 2 => (arb_canonical_body(), 0usize..3, any::()).prop_map( + |(mut body, word, value)| { + let start = 4usize.saturating_add(word.saturating_mul(32)); + if let Some(slot) = body.get_mut(start..start.saturating_add(32)) { + slot.copy_from_slice(&U256::from(value).to_be_bytes::<32>()); + } + body + } + ), + 1 => prop::collection::vec(any::(), 0..=512).prop_map(move |mut body| { + let mut calldata = selector.to_vec(); + calldata.append(&mut body); + calldata + }), + 1 => prop::collection::vec(any::(), 0..=512), + ] + } + + /// Gas values clustered around every threshold the precompile checks. + fn arb_gas() -> impl Strategy { + prop_oneof![ + 1 => Just(0u64), + 2 => 0u64..=1_000, + 3 => VERIFY_BASE_GAS.saturating_sub(64)..=VERIFY_BASE_GAS.saturating_add(8_192), + 1 => any::(), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + /// Reserve-first must be observably identical to the legacy decode-then-charge order + /// for every input, gas, and reservoir: same status, bytes, gas used, and reservoir. + #[test] + fn reserve_first_matches_legacy_ordering( + input in arb_input(), + gas in arb_gas(), + reservoir in any::(), + ) { + let current = run_pq_precompile(gas, &input, reservoir) + .expect("pq precompile never returns Err"); + let legacy = legacy_run_pq_precompile(gas, &input, reservoir) + .expect("pq precompile never returns Err"); + prop_assert_eq!(current, legacy); + } + } } From 6d1eaaf017d4f5ae8b9aa9588d6e702e407cfe67 Mon Sep 17 00:00:00 2001 From: lihe Date: Thu, 8 Oct 2026 15:56:31 +0800 Subject: [PATCH 2/2] docs: record v0.8.1 merge validation --- docs/debank/merge-v0.8.1.md | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/debank/merge-v0.8.1.md diff --git a/docs/debank/merge-v0.8.1.md b/docs/debank/merge-v0.8.1.md new file mode 100644 index 0000000..d45112c --- /dev/null +++ b/docs/debank/merge-v0.8.1.md @@ -0,0 +1,145 @@ +# Arc v0.8.1 upstream 合并验证 + +日期:2026-10-08。合并分支 `merge-v0.8.1`,审阅入口 [PR #14](https://github.com/Chaintable/arc-node/pull/14)。按用户指示,在测试节点追平链头前合并并发布 `v0.8.1-ct.1`;近链头 hash / trace 对比在合并时尚未完成,测试节点继续追块,结果另行补记。 + +## 1. 升级内容与必要性 + +[上游 v0.8.1](https://github.com/circlefin/arc-node/releases/tag/v0.8.1) 的 release notes 只有一条 #486,没有升级优先级或截止时间的说明,不含硬分叉,也不改已出块的执行结果。上游 main 在 v0.8.0 之后的其他 commit(#467 等)不在这个 tag 里。 + +#486 修的是 PQ(SLH-DSA-SHA2-128s)precompile `0x1800…0004` 的 gas 计费顺序。v0.8.0 先把三个 `bytes` 参数完整解码并复制出来,再扣 base gas(230,000)和 message gas;calldata 可以让三个 offset 指向同一个大数组,于是 gas 不足的调用也会先复制 3 倍输入再 out of gas。v0.8.1 先 tokenize、type check,扣完 gas 再复制。 + +新旧实现对任意输入的判定、gas used 和返回值相同,依据是源码:在锁定的 alloy-sol-types 1.5.7 中,旧版 `abi_decode_raw_validate` 就是 `abi::decode_sequence` → `type_check` → `detokenize` 三步(`src/types/function.rs:75`、`src/types/ty.rs:307`),新版调用同样的前两步,只把 `detokenize` 移到扣费之后;`bytes` 的 `type_check` 恒为真,`detokenize` 只复制已校验的切片、不会失败,message 长度在复制前后相同。所以非规范编码(offset 别名或不对齐、非零或缺失 padding、尾部多余字节)的接受与拒绝完全由同一个 `decode_sequence` 决定,200 gas 早期失败与扣费后失败的分界不变。上游的差分 proptest(512 个样本)是补充证据。该 precompile 在主网无 hardfork 门控,区块执行和 `eth_call`、`trace_debankBlock` 等路径都会走到。 + +必要性中:不升级也能正常同步,升级可以去掉这个放大开销,改动小、没有冲突、输出不变。 + +malachite 上游 10-06 发布了 v0.8.1,但 arc-node v0.8.1 仍 pin `tag = "v0.8.0"`,本次不涉及。 + +## 2. Merge 冲突与影响面 + +从 Chaintable `main=17634bc`(v0.8.0-ct.4 + PR #13)合入上游 `v0.8.1=ab09dbf`,merge commit `9a4fe71`,无冲突。上游改了 `crates/pq-precompile/{Cargo.toml,src/lib.rs}` 和 `Cargo.lock`(`arc-pq-precompile` 新增 `proptest` dev-dependency,一行);与 fork patch 只在 `Cargo.lock` 重叠,git 自动合并。 + +保留的 fork patch(本次未改动): + +- `debank-rpc` crate 及 node 注册:`trace_debankBlock`(通用节点 ETL 拉取)、`pre_traceMany`、`eth_multiCall` 和查询类 RPC。 +- `crates/evm`:保留 Arc subcall precompile 的子调用 trace;与 PQ precompile 无交集。 +- Chaintable 公共 ECR 的 `build.yml` / `release.yml`,镜像加 shell 与独立 `kill`(PR #11、#13),版本元数据构建脚本。 + +## 3. 部署情况 + +生产 `production/blockchain-arc` 只读实测(2026-10-08):只有通用节点。主 writer `nodex-node-88aa7766`(`arc-writer` / `arc-consensus:v0.8.0-ct.3`)和 seed `nodex-node-seed`(`v0.8.0-ct.4`,ct.3→ct.4 只改 Dockerfile),pod 为 init(删 `discovery-secret`)+ node(EL)+ consensus(CL,follow 模式,`--follow.endpoint=https://rpc.mainnet.arc.io/`)+ jrpcx。投递在独立的 `etl-88aa7766`,通过 `trace_debankBlock` 拉取;rpc / state-rpc 是 leafage-evm-x,读 Kafka/S3,不直连 writer。没有常规节点。 + +测试机 lihe-dev: + +- 数据卷:seed 周备份快照 `snap-067827e3b3b3534db`(10-02 00:02Z,数据 145.2 GiB,高度 23,791,039)建 `vol-0d800f1fdb5a910c7`(150 GiB gp3,6000 IOPS / 250 MiB/s,初始化速率 300 MiB/s),挂 `/opt/app/arc/writer_merge_v0.8.1/data`。 +- 只运行 EL 和 CL,不运行 etl / leafage,不连接 Kafka/S3。EL `--disable-discovery`,CL follow 模式不监听 p2p;首次启动前删除 `execution/discovery-secret`,EL 启动时(02:44:09Z)重新生成,新 enode 前缀 `dd852db9c29f739d`。端口只绑 127.0.0.1,独立网络 `10.99.76.0/24`。 +- 镜像:PR CI run 37717180605 产出的 `public.ecr.aws/b2h7a5c4/chaintable/arc-node:9a4fe718`、`arc-consensus:9a4fe718`,二进制版本输出 commit `9a4fe71828e9`。 +- 参数与生产相同,差别:EL 的 `--arc-rpc-upstream-url` 从同 pod 的 `127.0.0.1:31000` 改为 compose service 名 `consensus:31000`;宿主端口映射;`restart: on-failure:5`、`stop_grace_period: 5m`。 + +```yaml +# arc v0.8.1 merge 验证(PR #14 镜像 9a4fe718)。生产 production/blockchain-arc nodex-node-* 的 node + consensus 两容器。 +# 只起 EL + CL;node 不投递(生产投递在独立 etl pod),本 compose 不含 etl/leafage,不连 Kafka/S3。 +# 数据卷 vol-0d800f1fdb5a910c7(snap-067827e3b3b3534db,seed 10-02)挂在 ./data,容器内路径与生产一致(/var/data)。 +name: arc-merge-v081 + +services: + node: + container_name: arc-merge-v081-node + image: public.ecr.aws/b2h7a5c4/chaintable/arc-node:9a4fe718 + user: "999:999" + entrypoint: ["/usr/local/bin/arc-node-execution"] + command: + - node + - --chain=arc-mainnet + - --datadir=/var/data/execution + - --disable-discovery + - --ipcpath=/var/data/run/reth.ipc + - --auth-ipc + - --auth-ipc.path=/var/data/run/auth.ipc + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=eth,net,web3,txpool,trace,debug,reth + - --metrics=0.0.0.0:9001 + - --enable-arc-rpc + - --arc-rpc-upstream-url=http://consensus:31000 # 生产同 pod 用 127.0.0.1:31000 + - --log.file.directory=/var/data/execution/logs + environment: + RUST_LOG: info + volumes: + - ./data:/var/data + ports: + - "127.0.0.1:18745:8545" + - "127.0.0.1:29301:9001" + mem_limit: 12g + cpus: 4 + restart: on-failure:5 + stop_grace_period: 5m + logging: + driver: json-file + options: {max-size: "100m", max-file: "5"} + networks: [arc-merge-v081-net] + + consensus: + container_name: arc-merge-v081-consensus + image: public.ecr.aws/b2h7a5c4/chaintable/arc-consensus:9a4fe718 + user: "999:999" + entrypoint: ["/usr/local/bin/arc-node-consensus"] + command: + - start + - --home=/var/data/consensus + - --eth-socket=/var/data/run/reth.ipc + - --execution-socket=/var/data/run/auth.ipc + - --rpc.addr=0.0.0.0:31000 + - --follow + - --follow.endpoint=https://rpc.mainnet.arc.io/ + - --metrics=0.0.0.0:29000 + - --execution-persistence-backpressure + - --execution-persistence-backpressure-threshold=10 + environment: + RUST_LOG: info + ARC_SYNC_PARALLEL_REQUESTS: "1" + ARC_SYNC_BATCH_SIZE: "5" + ARC_SYNC_REQUEST_TIMEOUT: "5s" + depends_on: + node: {condition: service_started} + volumes: + - ./data:/var/data + ports: + - "127.0.0.1:31010:31000" + - "127.0.0.1:29010:29000" + mem_limit: 4g + cpus: 2 + restart: on-failure:5 + stop_grace_period: 5m + logging: + driver: json-file + options: {max-size: "100m", max-file: "5"} + networks: [arc-merge-v081-net] + +networks: + arc-merge-v081-net: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.99.76.0/24 +``` + +## 4. 部署后测试情况 + +- 构建:`cargo check --workspace --all-targets --locked` 通过;`cargo test -p arc-pq-precompile -p arc-precompiles --locked`:14/14(含上游差分测试 `reserve_first_matches_legacy_ordering`),68 passed 1 ignored。PR CI amd64 / arm64 两个镜像均成功。 +- 启动:02:39:38Z 启动 EL,卷初始化期间 EL 打开数据库约 4.5 分钟,02:44:09Z 建出 IPC;此前 CL 等待 EL 超时按设计退出重启,EL 就绪后 02:44:29Z 启动 CL,之后 EL / CL restart 均为 0,日志无 ERROR。 +- 追块:新代码从 23,791,040 开始执行。卷初始化完成后约 878 块/分钟(生产同步参数 `ARC_SYNC_PARALLEL_REQUESTS=1`、`ARC_SYNC_BATCH_SIZE=5`),主网约 2 块/s。07:56Z 本地 24,089,522,官方 24,868,539,落后 779,017 块;最近一小时净追约 16.5 块/s,预计 21:00Z 前后追平。 +- 区块 hash(参照官方 RPC,chainId 5042):23,791,040–059(新代码执行的头 20 块)20/20 一致,23,795,551–570 20/20 一致。近链头段在合并时尚未做。 +- `trace_debankBlock`(参照生产主 writer `nodex-node-88aa7766-0`):23,791,040、23,792,379–382 共 5 块,按流水线归一化规则(`scripts/pipe.py verify trace`:`storage_contracts` 排序、`state_diff` 以 stateRoot 代替)5/5 一致;同 5 块原始 JSON 只剔除 `process_start_timestamp` 后 5/5 逐字节一致。这 5 块是否含 PQ 调用未核实,PQ 的覆盖靠下面的定向调用。近链头段在合并时尚未做。 +- PQ precompile 同块对比(块 23,793,335 至 24,086,814,测试节点 vs 生产主 writer,`eth_call` 与 `debug_traceCall` callTracer 完整响应): + - 直接调用 10 组:空输入、错误 selector、只有 selector、参数长度不合法、合法长度但签名无效(完整验签返回 false)、64 字节别名输入、1 MiB 别名输入配 4 档 gas,全部一致。其中 1 MiB / 500 万 gas 一组两边都止于 `intrinsic gas too low`,没有进入 precompile,实际进入 PQ 的是 9 组。 + - 用 state override 注入一段合约,以 0 / 200k / 300k / 1M gas `STATICCALL` precompile,body 为 64 字节和 1 MiB,共 8 组,全部一致;其中 0、200k 和 300k(1 MiB)三档在 precompile 内 out of gas,即 #486 改动的路径。直接对 precompile 发交易打不到这条路径:1 MiB calldata 的 EIP-7623 floor 约 1050 万 gas,留给 precompile 的 gas 总是够用。 + - 非规范 ABI 输入 13 类(尾部多余字节、非零 padding、offset 越界或为 u256 最大值、长度高位非零、长度超出 body、vk/sig 别名、全空、截断、只有 offset、会解码失败的不对齐 offset)× 直接调用 1M gas、STATICCALL 0 和 240k gas,全部一致。 + - 能成功解码的非规范布局 3 类(不对齐 offset、offset 指回 head、缺少 padding)× 直接调用、STATICCALL 0 / 240k / 1M gas,全部一致,并断言 callTracer 中确实有对 PQ 的调用。 + - 前面几组脚本只比较两边响应是否相同,不检查目标路径是否执行;以上结论已按 trace 中的 PQ 调用逐项核对。 +- 交叉审查:Codex 与 Kimi 独立只读审查 PR head `9a4fe71`,均 approve,未发现本次引入的代码问题;两者都从 alloy-sol-types 1.5.7 源码确认 #486 前后等价。 + +## 5. 其他问题 + +- 上游 main 的 #467(CL 等待 EL 期间打开着 redb 且未装 SIGTERM handler,被 kill 后下次启动要全量 repair)不在 v0.8.1 里。部署时 EL 未建出 IPC 之前不要停 CL,生产 pod 重建时同样适用。