feat(op-reth): add Soul Gas Token (SGT) support with acceptance tests#148
feat(op-reth): add Soul Gas Token (SGT) support with acceptance tests#148blockchaindevsh wants to merge 18 commits intoop-esfrom
Conversation
Add SGT support to the Rust OP Stack implementation and execution-client agnostic acceptance tests, enabling alternative gas payment using Soul Gas Tokens from a predeploy contract. Rust changes (under rust/): - Patch revm/alloy-evm with forked SGT-enabled versions (revm 34.0.0 based) - Add SgtConfig type and SGT constants (contract addr, balance slot) - Parse SGT config from genesis config.optimism (soulGasTokenTime, isSoulBackedByNative) — same format as op-geth - Thread SGT config through OpBlockExecutionCtx → EVM via configure_sgt() - Add OpHardforks trait methods: is_sgt_active_at_timestamp(), is_sgt_native_backed() - RPC: dual HTTP server support (--http.sgt.addr/port) returning native+SGT combined balance on eth_getBalance - TxPool: check native+SGT combined balance for tx validation (only when SGT is active, zero overhead on non-SGT chains) - CLI: --http.sgt.addr and --http.sgt.port arguments Acceptance tests (20/20 passing): - Deposit: basic, accumulation, multi-account independence, native backing - Gas payment: native-only, full SGT, full SGT+native, partial SGT - Gas payment + tx value: full SGT, partial SGT - Insufficient funds: SGT-only, native-only, combined, with tx value - Balance invariant: preSGT + preNative == postSGT + postNative + gasCost + txValue - Genesis: contract deployment, balanceOf returns zero - Smoke: native transfer, balance query Gas payment priority (enforced by forked revm handler): - Deduction: SGT first → native second - Refund: native first → SGT second No MSRV bump (stays 1.88), no reth version bump (stays v1.11.0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move SGT configuration from OpBlockExecutionCtx + Evm::configure_sgt() to CfgEnv fields, matching how OpSpecId passes hardfork info. This eliminates the alloy-evm fork entirely. Changes: - Remove configure_sgt() from Evm trait impl and OpEvm inherent method - Remove sgt_config from OpBlockExecutionCtx - Set CfgEnv.sgt_enabled/sgt_is_native_backed in evm_env(), next_evm_env(), evm_env_for_payload() - Switch revm fork from blockchaindevsh/revm to QuarkChain/revm@3a6aa085 which adds is_sgt_enabled()/is_sgt_native_backed() to Cfg trait - Drop alloy-evm fork (blockchaindevsh/evm) from [patch.crates-io] External dependency: QuarkChain/revm@3a6aa085 (only fork needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update revm fork to 1744019b which propagates DB errors in SGT balance operations. Deduplicate SGT slot calculation in txpool validator by reusing sgt_balance_slot and SGT_CONTRACT from op-revm. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rust/op-reth/crates/node/src/args.rs
Outdated
| pub http_sgt_addr: Option<String>, | ||
|
|
||
| /// HTTP server port for SGT-enabled eth_getBalance endpoint | ||
| #[arg(long = "http.sgt.port", value_name = "SGT_HTTP_PORT", default_value_t = 8546)] |
There was a problem hiding this comment.
Would there be a conflict because 8546 is the default websocket port?
There was a problem hiding this comment.
Fixed in 4277970, now the default value is 8645, the same as op-geth.
Picks up SGT fixes: reset deduction state between transactions, remove unreachable balance check, and propagate DB errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Picks up collect_native_balance for SGT fee burning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Picks up fix to decrement SGT pool tracking after gas refund, ensuring collect_native_balance sees correct post-refund pool values. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| SealedHeader::seal_slow(make_op_genesis_header(&inner.genesis, &inner.hardforks)); | ||
|
|
||
| OpChainSpec { inner } | ||
| OpChainSpec { |
There was a problem hiding this comment.
I think the new SGT config is getting lost on the builder conversion path. OpChainSpecBuilder::build() and From<ChainSpec> hardcodes sgt_activation_timestamp: None, but the genesis parsing path does populate it from config.optimism.soulGasTokenTime. That means any code path that constructs an OpChainSpec via ...genesis(genesis).build() will silently disable SGT, even when the genesis config enables it
e.g. node/utils.rs (line 20) and custom_genesis.rs (line 33)
There was a problem hiding this comment.
Fixed in 0a29eff. Note this code path is only used in tests, the main node startup path uses From<Genesis> for OpChainSpec instead.
| let cost = valid_tx.transaction().cost().saturating_add(cost_addition); | ||
|
|
||
| // Check combined balance (native + SGT) only when SGT is active | ||
| let effective_balance = if self.chain_spec().is_sgt_active_at_timestamp(self.block_timestamp()) { |
There was a problem hiding this comment.
I think this may be slightly off around the activation boundary. The check uses the current head timestamp (self.block_timestamp()), whereas txpool validation usually needs to match the rules of the next block the tx could be included in. op-geth appears to use head.Time + 2 here. If parity with op-geth is the goal, this probably should use the next-block timestamp rather than the current head timestamp.
| .map_err(Self::Error::from_eth_err)? | ||
| .unwrap_or_default(); | ||
|
|
||
| if !sgt_mode { |
There was a problem hiding this comment.
This looks rollout-gated rather than chain-state-gated. When sgt_mode is true, eth_getBalance always returns native + SGT, but I don’t see a check that SGT is active for the queried block. That can over-report balances before soulGasTokenTime and for historical pre-activation blocks.
| .map_err(|e| eyre::eyre!("Failed to merge SGT eth RPC methods: {}", e))?; | ||
|
|
||
| let socket_for_log = socket; | ||
| tokio::spawn(async move { |
There was a problem hiding this comment.
I think startup failures for the SGT RPC server are currently detached from the node launch path. Because ServerBuilder::build(socket) runs inside tokio::spawn, a bind failure (for example, port already in use) will only be logged and the node itself will still start successfully. If the SGT endpoint is explicitly configured, should this fail node startup instead?
rust/op-reth/crates/node/src/node.rs
Outdated
| flashblocks_url: None, | ||
| flashblock_consensus: false, | ||
| http_sgt_addr: None, | ||
| http_sgt_port: 8546, |
There was a problem hiding this comment.
The default SGT port is inconsistent here (8546 vs CLI default 8645). In the current public builder API this value appears to be mostly dormant, because CLI startup overrides it and with_sgt_http() requires an explicit port anyway. Still, it would be good to keep these defaults aligned to avoid future confusion or accidental divergence if additional builder paths are added.
There was a problem hiding this comment.
Fixed in b8ce739, unified with the DEFAULT_SGT_HTTP_PORT constant.
…ild() OpChainSpecBuilder::build() hardcoded sgt_activation_timestamp: None, silently disabling SGT even when the genesis config enables it via config.optimism.soulGasTokenTime. Extract parse_sgt_config() helper and call it in build() so the builder path is consistent with the From<Genesis> path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pool The txpool validator checked SGT activation against the current head timestamp, but transactions will be included in the next block. Use head.Time + 2 to match op-geth's next-block estimation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When sgt_mode is true, eth_getBalance always returned native + SGT combined balance regardless of block timestamp. This over-reports balances for historical pre-activation blocks. Add sgt_activation_timestamp to OpHardforks trait and OpEthApi, check the queried block's timestamp before including SGT balance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ServerBuilder::build(socket) ran inside tokio::spawn, so a bind failure (e.g., port already in use) was only logged and the node started anyway. Bind the TcpListener eagerly and pass it to build_from_tcp so failures surface at node startup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…constant The builder default was 8546 while the CLI default was 8645. Extract a single DEFAULT_SGT_HTTP_PORT constant (8645) and use it everywhere. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| use reth_storage_api::{BlockNumReader, HeaderProvider}; | ||
| use alloy_consensus::BlockHeader as _; | ||
| let block_number = this.provider() | ||
| .last_block_number() |
There was a problem hiding this comment.
I think this is still checking SGT activation against the latest canonical header rather than the queried block_id. We already load state for the requested block via state_at_block_id_or_latest(block_id), but the activation gate below uses last_block_number() / header_by_number(block_number). That means historical queries can still be wrong: once the chain is post-activation, a pre-activation block queried through the SGT endpoint can still return native + SGT. It seems like this should derive the timestamp from the same block that block_id resolves to.
|
|
||
| // Check if SGT is active at the queried block's timestamp. | ||
| // Skip SGT balance for pre-activation blocks to avoid over-reporting. | ||
| if let Some(activation) = sgt_activation { |
There was a problem hiding this comment.
There is also a corner case when soulGasTokenTime is not configured. If sgt_activation_timestamp is None, this branch is skipped and the code falls through to always adding SGT. That would make the SGT endpoint report native + SGT even on chains where SGT is not configured at all. It seems safer to treat None as “SGT inactive” here.
| impl From<ChainSpec> for OpChainSpec { | ||
| fn from(value: ChainSpec) -> Self { | ||
| Self { inner: value } | ||
| Self { inner: value, sgt_activation_timestamp: None, sgt_is_native_backed: true } |
There was a problem hiding this comment.
OpChainSpecBuilder::build() is fixed now, but this public conversion still drops the SGT config. From<ChainSpec> for OpChainSpec hardcodes sgt_activation_timestamp: None and sgt_is_native_backed: true, so any caller that goes through this conversion can still silently lose the parsed SGT settings. Even if there is no current runtime hit in-tree, it would be good to keep this conversion consistent with the rest of the type.
|
I don’t think op-acceptance-tests are sufficient. We should spin up a devnet using op-reth and op-node. We should also define a test plan that covers the scenarios for the SGT feature. |
…gured chains - Resolve block_id to actual queried block timestamp instead of using latest head, so pre-activation historical blocks return native-only - Return native-only when sgt_activation_timestamp is None (SGT not configured) instead of falling through to combined balance - Parse SGT config from genesis in From<ChainSpec> instead of hardcoding None Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
opup now uses op-reth by default, we can spin up devnet tests using the same steps here after these feature PRs are merged. |
I don’t have any further comments. It would be great to spin up a devnet and test before merging, if you haven’t done so already. |
The inner EthTransactionValidator performs a native-only balance check that rejects zero-ETH senders before the SGT-aware apply_op_checks can run. Disable the inner balance check when SGT is configured so that apply_op_checks handles combined (native + SGT) balance validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The txpool had two places checking native-only balance, causing SGT transactions from zero-ETH senders to be rejected or demoted: 1. Inner EthTransactionValidator balance check: fixed by calling disable_balance_check() when SGT is configured. 2. Pool maintenance ENOUGH_BALANCE check: fixed by switching reth dependency to QuarkChain/reth fork which adds an additional_balance_provider callback. Op-reth sets this callback to provide SGT balance, so pool maintenance uses native + SGT for the balance check. All 20 SGT acceptance tests pass with DEVSTACK_L2EL_KIND=op-reth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
I can finish the steps until "Test SGT" of opup_devnet_test.md now. It turns out we also need to fork the reth repo to keep the sgt tx from being demoted. |
The go changes mainly migrates the sgt e2e tests to the op-acceptance tests(
cd op-acceptance-tests && just sgt).Essential op-reth changes are under the ./rust directory.
Details of this PR can be seen in the msg of the squashed commit.
External rust dependencies PRs will be created separately.