Skip to content

feat(op-reth): add Soul Gas Token (SGT) support with acceptance tests#148

Open
blockchaindevsh wants to merge 18 commits intoop-esfrom
sgt_rust_v2
Open

feat(op-reth): add Soul Gas Token (SGT) support with acceptance tests#148
blockchaindevsh wants to merge 18 commits intoop-esfrom
sgt_rust_v2

Conversation

@blockchaindevsh
Copy link
Copy Markdown

@blockchaindevsh blockchaindevsh commented Mar 20, 2026

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.

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>
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)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there be a conflict because 8546 is the default websocket port?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4277970, now the default value is 8645, the same as op-geth.

blockchaindevsh and others added 2 commits March 31, 2026 22:32
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>
blockchaindevsh and others added 2 commits April 1, 2026 11:37
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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Author

@blockchaindevsh blockchaindevsh Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f8e001f.

.map_err(Self::Error::from_eth_err)?
.unwrap_or_default();

if !sgt_mode {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce4f3f7.

.map_err(|e| eyre::eyre!("Failed to merge SGT eth RPC methods: {}", e))?;

let socket_for_log = socket;
tokio::spawn(async move {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, fixed in ed05f8f.

flashblocks_url: None,
flashblock_consensus: false,
http_sgt_addr: None,
http_sgt_port: 8546,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b8ce739, unified with the DEFAULT_SGT_HTTP_PORT constant.

blockchaindevsh and others added 5 commits April 3, 2026 21:12
…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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5894688.


// 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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5894688.

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 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5894688.

@qzhodl
Copy link
Copy Markdown

qzhodl commented Apr 8, 2026

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>
@blockchaindevsh
Copy link
Copy Markdown
Author

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.

opup now uses op-reth by default, we can spin up devnet tests using the same steps here after these feature PRs are merged.

@qzhodl
Copy link
Copy Markdown

qzhodl commented Apr 9, 2026

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.

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.

blockchaindevsh and others added 5 commits April 10, 2026 11:35
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>
@blockchaindevsh
Copy link
Copy Markdown
Author

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants