diff --git a/Cargo.lock b/Cargo.lock index 81846c1..ef3dd9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10886,6 +10886,7 @@ dependencies = [ "stbl-primitives-fee-compatible-api", "stbl-primitives-zero-gas-transactions-api", "stbl-tools", + "stbl-zero-gas-fetcher", "substrate-build-script-utils", "substrate-frame-rpc-system", "substrate-prometheus-endpoint", @@ -11211,6 +11212,32 @@ dependencies = [ "stbl-tools", ] +[[package]] +name = "stbl-zero-gas-fetcher" +version = "1.0.0" +dependencies = [ + "account", + "ethereum", + "fp-rpc", + "futures 0.3.31", + "futures-timer", + "hex", + "log", + "reqwest", + "sc-client-api", + "sc-transaction-pool-api", + "serde", + "sp-api", + "sp-blockchain", + "sp-core", + "sp-keystore", + "sp-runtime", + "stability-runtime", + "stbl-primitives-zero-gas-transactions-api", + "stbl-tools", + "substrate-prometheus-endpoint", +] + [[package]] name = "str0m" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index bbdc9a8..d792ad2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ 'pallets/root-controller', 'stability-rpc', 'client/authorship', + 'client/zero-gas-fetcher', 'primitives/core', 'primitives/account', 'primitives/transaction-validator', @@ -207,6 +208,7 @@ precompile-utils = { git = "https://github.com/stabilityprotocol/frontier", bran # Stability Client stbl-cli-authorship = { path = './client/authorship' } stbl-proposer-metrics = { path = './client/proposer-metrics' } +stbl-zero-gas-fetcher = { path = './client/zero-gas-fetcher' } # Stability pallet-evm-precompile-blake2 = { git = "https://github.com/stabilityprotocol/frontier", branch = "stable2407", default-features = false } pallet-evm-precompile-bn128 = { git = "https://github.com/stabilityprotocol/frontier", branch = "stable2407", default-features = false } diff --git a/client/zero-gas-fetcher/Cargo.toml b/client/zero-gas-fetcher/Cargo.toml new file mode 100644 index 0000000..7977cf4 --- /dev/null +++ b/client/zero-gas-fetcher/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "stbl-zero-gas-fetcher" +version = "1.0.0" +authors = ["Stability Solutions"] +edition = "2021" +license = "GPL-3.0-or-later WITH Classpath-exception-2.0" +description = "Background worker that fetches zero-gas transactions from an external pool and enqueues them into the Substrate transaction pool." +repository = "https://github.com/stabilityprotocol/stability/" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +futures = { workspace = true } +futures-timer = { workspace = true } +log = { workspace = true } +hex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +reqwest = { workspace = true, features = ["json", "gzip"] } +ethereum = { workspace = true } +prometheus-endpoint = { workspace = true } + +# Substrate +sc-client-api = { workspace = true } +sc-transaction-pool-api = { workspace = true } +sp-api = { workspace = true } +sp-blockchain = { workspace = true } +sp-core = { workspace = true } +sp-keystore = { workspace = true } +sp-runtime = { workspace = true } + +# Frontier +fp-rpc = { workspace = true } + +# Stability +stbl-primitives-zero-gas-transactions-api = { workspace = true, features = ["std"] } +stbl-tools = { workspace = true } +account = { workspace = true, features = ["std"] } +stability-runtime = { path = "./../../runtime", features = ["std"] } diff --git a/client/zero-gas-fetcher/src/lib.rs b/client/zero-gas-fetcher/src/lib.rs new file mode 100644 index 0000000..503e0b6 --- /dev/null +++ b/client/zero-gas-fetcher/src/lib.rs @@ -0,0 +1,418 @@ +// Copyright © 2022 STABILITY SOLUTIONS, INC. ("STABILITY") +// This file is part of the Stability Global Trust Network client +// software and accompanying documentation (the "Software"). + +// You can download and use the Software for free under the terms of +// the Stability Open License Agreement as published by Stability on +// Github at https://github.com/stabilityprotocol/stability/blob/master/LICENSE. + +// THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +// STABILITY EXPRESSLY DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, +// INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND +// NON-INFRINGEMENT. IN NO EVENT SHALL OWNER BE LIABLE FOR ANY +// INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING +// OUT OF USE OF THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +// SUCH DAMAGES. + +// Please see the Stability Open License Agreement for more +// information. + +//! Background worker that fetches zero-gas transactions from an external HTTP pool +//! and submits them into the Substrate transaction pool (mempool). +//! +//! This decouples the HTTP fetch from the block proposal hot path, allowing the +//! proposer to pick up ZGTs from `pool.ready()` like any other transaction. + +use fp_rpc::EthereumRuntimeRPCApi; +use futures::{future::FutureExt, select}; +use log::{debug, error, info, warn}; +use prometheus_endpoint::{register, Counter, Histogram, HistogramOpts, Opts, Registry, U64}; +use sc_transaction_pool_api::{TransactionPool, TransactionSource}; +use sp_api::ProvideRuntimeApi; +use sp_blockchain::HeaderBackend; +use sp_core::crypto::KeyTypeId; +use sp_keystore::{Keystore, KeystorePtr}; +use sp_runtime::{ + traits::{Block as BlockT, NumberFor}, + SaturatedConversion, +}; +use stbl_primitives_zero_gas_transactions_api::ZeroGasTransactionApi; +use std::{ + collections::HashMap, + sync::Arc, + time, +}; + +const LOG_TARGET: &str = "zero-gas-fetcher"; + +/// Maximum number of transaction hashes to keep in the dedup set. +/// Entries older than this many blocks are evicted. +const DEDUP_BLOCK_TTL: u32 = 25; + +#[derive(serde::Deserialize)] +struct RawZeroGasTransactionResponse { + transactions: Vec, +} + +/// Prometheus metrics for the ZGT fetcher. +#[derive(Clone)] +pub struct FetcherMetrics { + pub fetch_time: Histogram, + pub submit_success: Counter, + pub submit_failure: Counter, + pub fetch_count: Counter, + pub dedup_skipped: Counter, +} + +impl FetcherMetrics { + pub fn register(registry: &Registry) -> Result { + Ok(Self { + fetch_time: register( + Histogram::with_opts(HistogramOpts::new( + "stability_zgt_fetcher_fetch_time", + "Histogram of time taken to fetch from the ZGT HTTP pool", + ))?, + registry, + )?, + submit_success: register( + Counter::with_opts(Opts::new( + "stability_zgt_fetcher_submit_success", + "Number of ZGTs successfully submitted to the Substrate pool", + ))?, + registry, + )?, + submit_failure: register( + Counter::with_opts(Opts::new( + "stability_zgt_fetcher_submit_failure", + "Number of ZGTs that failed to submit to the Substrate pool", + ))?, + registry, + )?, + fetch_count: register( + Counter::with_opts(Opts::new( + "stability_zgt_fetcher_fetch_count", + "Total number of ZGTs fetched from the HTTP pool", + ))?, + registry, + )?, + dedup_skipped: register( + Counter::with_opts(Opts::new( + "stability_zgt_fetcher_dedup_skipped", + "Number of ZGTs skipped due to deduplication", + ))?, + registry, + )?, + }) + } +} + +/// Spawns a background worker task that periodically polls the external ZGT HTTP pool +/// and submits fetched transactions into the Substrate transaction pool. +/// +/// # Arguments +/// +/// * `spawn_handle` - Handle to spawn the background task +/// * `client` - Substrate client for runtime API calls +/// * `pool` - Substrate transaction pool for submission +/// * `keystore` - Keystore for signing consent messages +/// * `url` - HTTP URL of the external ZGT pool +/// * `timeout_ms` - Timeout in milliseconds for the HTTP request +/// * `poll_interval_ms` - Interval in milliseconds between poll cycles +/// * `prometheus` - Optional Prometheus registry for metrics +pub fn spawn_zero_gas_fetcher( + spawn_handle: impl sp_core::traits::SpawnNamed + 'static, + client: Arc, + pool: Arc

, + keystore: KeystorePtr, + url: String, + timeout_ms: u64, + poll_interval_ms: u64, + prometheus: Option<&Registry>, +) where + Block: BlockT, + C: HeaderBackend + ProvideRuntimeApi + Send + Sync + 'static, + C::Api: EthereumRuntimeRPCApi + + ZeroGasTransactionApi, + P: TransactionPool + Send + Sync + 'static, + NumberFor: Into, +{ + let metrics = prometheus.and_then(|registry| { + FetcherMetrics::register(registry) + .map_err(|err| { + warn!( + target: LOG_TARGET, + "Failed to register ZGT fetcher prometheus metrics: {}", err + ) + }) + .ok() + }); + + info!( + target: LOG_TARGET, + "🚀 Starting zero-gas transaction fetcher (poll interval: {}ms, timeout: {}ms, url: {})", + poll_interval_ms, + timeout_ms, + url, + ); + + spawn_handle.spawn( + "zero-gas-fetcher", + Some("zero-gas-transactions"), + Box::pin(zero_gas_fetcher_loop::( + client, + pool, + keystore, + url, + timeout_ms, + poll_interval_ms, + metrics, + )), + ); +} + +async fn zero_gas_fetcher_loop( + client: Arc, + pool: Arc

, + keystore: KeystorePtr, + url: String, + timeout_ms: u64, + poll_interval_ms: u64, + metrics: Option, +) where + Block: BlockT, + C: HeaderBackend + ProvideRuntimeApi + Send + Sync + 'static, + C::Api: EthereumRuntimeRPCApi + + ZeroGasTransactionApi, + P: TransactionPool + Send + Sync + 'static, + NumberFor: Into, +{ + // Dedup map: ethereum tx hash -> block number when first seen + let mut seen_txs: HashMap = HashMap::new(); + let http_client = reqwest::Client::new(); + + loop { + // Wait for the poll interval + futures_timer::Delay::new(std::time::Duration::from_millis(poll_interval_ms)).await; + + let best_hash = client.info().best_hash; + let best_number: u64 = client.info().best_number.into(); + let current_block_u32 = best_number.saturated_into::(); + + // Evict old entries from the dedup set + seen_txs.retain(|_, block| { + current_block_u32.saturating_sub(*block) < DEDUP_BLOCK_TTL + }); + + // Fetch from the external ZGT pool + let fetch_start = time::Instant::now(); + + let raw_txs = match fetch_zero_gas_transactions( + &http_client, + &url, + timeout_ms, + ) + .await + { + Ok(txs) => txs, + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to fetch from ZGT pool: {}", e + ); + continue; + } + }; + + let fetch_duration = fetch_start.elapsed(); + if let Some(ref m) = metrics { + m.fetch_time.observe(fetch_duration.as_secs_f64()); + } + + if raw_txs.transactions.is_empty() { + debug!( + target: LOG_TARGET, + "No transactions from ZGT pool (fetched in {:?})", fetch_duration + ); + continue; + } + + info!( + target: LOG_TARGET, + "📥 Fetched {} txns from ZGT enqueue pool ({:?}ms)", + raw_txs.transactions.len(), + fetch_duration.as_millis(), + ); + + if let Some(ref m) = metrics { + m.fetch_count.inc_by(raw_txs.transactions.len() as u64); + } + + // Get validator keys for signing + let keys = Keystore::ecdsa_public_keys( + &*keystore, + KeyTypeId::try_from("aura").unwrap_or_default(), + ); + + if keys.is_empty() { + warn!( + target: LOG_TARGET, + "No ECDSA keys found in keystore for 'aura'. Cannot sign consent messages." + ); + continue; + } + + // Sign the consent message for best_number + 1 + let signing_block = best_number.saturating_add(1); + + let chain_id = match client.runtime_api().chain_id(best_hash) { + Ok(id) => id, + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to get chain_id from runtime API: {}", e + ); + continue; + } + }; + + let message: Vec = b"I consent to validate zero gas transactions in block " + .iter() + .chain(signing_block.to_string().as_bytes().iter()) + .chain(b" on chain ") + .chain(chain_id.to_string().as_bytes().iter()) + .cloned() + .collect(); + + let public = keys[0].clone().into(); + let eip191_message = stbl_tools::eth::build_eip191_message_hash(message); + + let validator_signature = match Keystore::ecdsa_sign_prehashed( + &*keystore, + KeyTypeId::try_from("aura").unwrap_or_default(), + &public, + &eip191_message.as_fixed_bytes(), + ) { + Ok(Some(sig)) => sig.0.to_vec(), + Ok(None) => { + error!( + target: LOG_TARGET, + "Keystore returned None for ECDSA signature" + ); + continue; + } + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to sign consent message: {}", e + ); + continue; + } + }; + + // Process each transaction + for hex_tx in &raw_txs.transactions { + let raw_tx = match hex::decode(hex_tx) { + Ok(bytes) => bytes, + Err(e) => { + debug!( + target: LOG_TARGET, + "Failed to decode hex transaction: {}", e + ); + continue; + } + }; + + let ethereum_tx: ethereum::TransactionV2 = + match ethereum::EnvelopedDecodable::decode(&raw_tx) { + Ok(tx) => tx, + Err(e) => { + debug!( + target: LOG_TARGET, + "Failed to RLP-decode Ethereum transaction: {:?}", e + ); + continue; + } + }; + + let tx_hash = ethereum_tx.hash(); + + // Skip if we've already seen this transaction recently + if seen_txs.contains_key(&tx_hash) { + if let Some(ref m) = metrics { + m.dedup_skipped.inc(); + } + continue; + } + + // Convert to unsigned extrinsic via runtime API + let extrinsic = match client.runtime_api().convert_zero_gas_transaction( + best_hash, + ethereum_tx.clone(), + validator_signature.clone(), + ) { + Ok(ext) => ext, + Err(e) => { + debug!( + target: LOG_TARGET, + "[{:?}] Failed to convert ZGT via runtime API: {}", tx_hash, e + ); + continue; + } + }; + + // Submit to the Substrate transaction pool with TransactionSource::Local + // so that validate_unsigned skips the consent signature check (which would + // fail because block_number() and find_author() are incorrect during pool validation) + match pool + .submit_one(best_hash, TransactionSource::Local, extrinsic) + .await + { + Ok(_) => { + debug!( + target: LOG_TARGET, + "[{:?}] Successfully submitted ZGT to pool", tx_hash + ); + seen_txs.insert(tx_hash, current_block_u32); + if let Some(ref m) = metrics { + m.submit_success.inc(); + } + } + Err(e) => { + debug!( + target: LOG_TARGET, + "[{:?}] Failed to submit ZGT to pool: {}", tx_hash, e + ); + if let Some(ref m) = metrics { + m.submit_failure.inc(); + } + } + } + } + } +} + +/// Fetch zero-gas transactions from the external HTTP pool with a timeout. +async fn fetch_zero_gas_transactions( + client: &reqwest::Client, + url: &str, + timeout_ms: u64, +) -> Result { + let mut request = Box::pin(client.post(url).send().fuse()); + let mut timeout = Box::pin( + futures_timer::Delay::new(std::time::Duration::from_millis(timeout_ms)).fuse(), + ); + + let response = select! { + res = request => { + res.map_err(|e| format!("HTTP request error: {}", e))? + }, + _ = timeout => { + return Err("HTTP request timed out".to_string()); + }, + }; + + response + .json::() + .await + .map_err(|e| format!("JSON parse error: {}", e)) +} diff --git a/docker/README.md b/docker/README.md index 66fe9b8..63d492a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -37,8 +37,10 @@ Optional environment variables: - BOOTNODES: This environment variable allows specifying the bootnodes to use, separated by commas. If not specified, the node will use the default bootnodes for the chain spec. - MODE: This environment variable allows the node to run in different pruning modes. Possible values are "full_node" or "archive". The default value is "full_node". - BACKEND_TYPE: The only available option is `sql` for now. This option allows the node to use a SQL backend instead of the default `key-value` backend for faster Ethereum log queries. -- ZERO_GAS_TX_POOL: This environment variable allows the node to run with a zero gas price transaction pool. By default the feature is disabled. The expected value is a string containing an URL. Check the [Zero Gas Transaction Pool](../docs/ZERO-GAS-TRANSACTIONS.md) document for more information. -- ZERO_GAS_TX_POOL_TIMEOUT: This environment variable allows specifying the timeout for the zero gas transaction in millisecond. If not specified, the node will use the default timeout (1000ms). +- ZERO_GAS_TX_POOL: This environment variable allows the node to run with a zero gas price transaction pool using the **inline** mode (HTTP fetch during block proposal). By default the feature is disabled. The expected value is a string containing a URL. Check the [Zero Gas Transaction Pool](../docs/ZERO-GAS-TRANSACTIONS.md) document for more information. +- ZERO_GAS_TX_POOL_TIMEOUT: This environment variable allows specifying the timeout for the zero gas transaction HTTP requests in milliseconds. Applies to both inline and enqueue modes. If not specified, the node will use the default timeout (1000ms). +- ZERO_GAS_TX_POOL_ENQUEUE: This environment variable enables the **enqueue** mode, where a background worker polls the external pool and submits zero-gas transactions into the Substrate mempool. This decouples the HTTP fetch from block production, improving block proposal performance. The expected value is a string containing a URL. Check the [Zero Gas Transaction Pool](../docs/ZERO-GAS-TRANSACTIONS.md) document for more information. +- ZERO_GAS_TX_POOL_ENQUEUE_INTERVAL: This environment variable allows specifying the poll interval for the enqueue mode background worker in milliseconds. If not specified, the node will use the default interval (3000ms). To set an environment variable in the docker run, use the flag -e NAME=VALUE diff --git a/docker/client/entrypoint.sh b/docker/client/entrypoint.sh index 9c5d3d3..f02e8df 100644 --- a/docker/client/entrypoint.sh +++ b/docker/client/entrypoint.sh @@ -65,6 +65,14 @@ if [ -n "$ZERO_GAS_TX_POOL_TIMEOUT" ]; then START_COMMAND="$START_COMMAND --zero-gas-tx-pool-timeout $ZERO_GAS_TX_POOL_TIMEOUT" fi +if [ -n "$ZERO_GAS_TX_POOL_ENQUEUE" ]; then + START_COMMAND="$START_COMMAND --zero-gas-tx-pool-enqueue $ZERO_GAS_TX_POOL_ENQUEUE" +fi + +if [ -n "$ZERO_GAS_TX_POOL_ENQUEUE_INTERVAL" ]; then + START_COMMAND="$START_COMMAND --zero-gas-tx-pool-enqueue-interval $ZERO_GAS_TX_POOL_ENQUEUE_INTERVAL" +fi + if [ -n "$CUSTOM_ETH_APIS" ]; then START_COMMAND="$START_COMMAND --ethapi=$CUSTOM_ETH_APIS" else diff --git a/docs/ZERO-GAS-TRANSACTIONS.md b/docs/ZERO-GAS-TRANSACTIONS.md index 176d21d..23fbe54 100644 --- a/docs/ZERO-GAS-TRANSACTIONS.md +++ b/docs/ZERO-GAS-TRANSACTIONS.md @@ -4,8 +4,14 @@ - [2. How do Zero Gas Transactions work?](#2-how-do-zero-gas-transactions-work) - [External Private Mempool](#external-private-mempool) - [Setting up the External Mempool for Validators](#setting-up-the-external-mempool-for-validators) -- [3. Other considerations](#3-other-considerations) -- [4. Diagram](#4-diagram) +- [3. Mempool Enqueue Mode (Background Fetcher)](#3-mempool-enqueue-mode-background-fetcher) + - [How it works](#how-it-works) + - [Setting up the Enqueue Mode](#setting-up-the-enqueue-mode) + - [Validator Consent Signature and Block Window](#validator-consent-signature-and-block-window) + - [Running Both Modes Simultaneously](#running-both-modes-simultaneously) +- [4. Other considerations](#4-other-considerations) +- [5. CLI Reference](#5-cli-reference) +- [6. Diagrams](#6-diagrams) ## 1. Introduction @@ -35,13 +41,92 @@ All transactions retrieved from this mempool will be processed as Zero Gas Trans If you are a validator and wish to integrate this functionality, you simply need to configure your node with the `--zero-gas-tx-pool ` parameter. This option determines the HTTP address to which the validating node will make POST requests to obtain the Zero Gas Transactions during its validation cycle. -## 3. Other considerations +In this mode (the **inline** mode), the node fetches transactions from the external pool **synchronously during block proposal**. The HTTP request happens on the critical path of block production, subject to the configured timeout. -- If the external mempool takes longer than 100ms to respond, the Zero Gas Transactions will be ignored. +## 3. Mempool Enqueue Mode (Background Fetcher) + +An alternative mode decouples the HTTP fetch from block production by running a **background worker** that polls the external pool and submits zero-gas transactions into the standard **Substrate transaction pool (mempool)**. The block proposer then picks them up naturally alongside regular transactions. + +This mode eliminates the latency impact of the HTTP fetch on block production, making the system faster and more resilient to external pool slowness or unavailability. + +### How it works + +1. A background task polls the configured HTTP endpoint at a regular interval (default: every 3 seconds). +2. Fetched transactions are decoded, deduplicated, and wrapped into unsigned extrinsics. +3. The validator signs a consent message (proving it agrees to include zero-gas transactions) and attaches the signature to each extrinsic. +4. Each extrinsic is submitted to the local Substrate transaction pool via `TransactionSource::Local`. +5. During the next block proposal, the proposer picks up these transactions from `pool.ready()` like any other transaction. +6. Full consent signature validation happens during block execution, ensuring security is preserved. + +### Setting up the Enqueue Mode + +Configure your node with the `--zero-gas-tx-pool-enqueue ` parameter: + +```bash +stability \ + --zero-gas-tx-pool-enqueue http://your-zgt-pool:8080/transactions \ + --zero-gas-tx-pool-enqueue-interval 3000 \ + --zero-gas-tx-pool-timeout 1000 +``` + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `--zero-gas-tx-pool-enqueue ` | HTTP URL of the external pool for the background fetcher | _(none, disabled)_ | +| `--zero-gas-tx-pool-enqueue-interval ` | Poll interval in milliseconds | `3000` | +| `--zero-gas-tx-pool-timeout ` | HTTP request timeout in milliseconds (shared with inline mode) | `1000` | + +### Validator Consent Signature and Block Window + +The validator signs a consent message of the form: + +``` +I consent to validate zero gas transactions in block {N} on chain {chain_id} +``` + +Since the background fetcher signs for `best_block + 1` but the transaction may be included several blocks later, the pallet accepts consent signatures within a **±10 block window**. This means a signature signed for block 50 is valid for execution at any block from 40 to 60. + +During pool validation (`TransactionSource::Local` or `External`), the consent signature check is skipped entirely — only the Ethereum transaction signature and nonce/chain-id checks are performed. The full consent validation is deferred to block execution time, where `block_number()` and `find_author()` return correct values. + +Transactions submitted to the pool have a **longevity of 20 blocks**, after which they are automatically evicted if not included in a block. The background fetcher will re-fetch and re-submit fresh transactions in subsequent poll cycles. + +### Running Both Modes Simultaneously + +Both modes can run at the same time with **different endpoints**: + +```bash +stability \ + --zero-gas-tx-pool http://pool-a:8080/inline \ + --zero-gas-tx-pool-enqueue http://pool-b:8080/enqueue \ + --zero-gas-tx-pool-timeout 1000 \ + --zero-gas-tx-pool-enqueue-interval 3000 +``` + +| Mode | Flag | Behavior | +|------|------|----------| +| Inline | `--zero-gas-tx-pool` | HTTP fetch during block proposal (original behavior) | +| Enqueue | `--zero-gas-tx-pool-enqueue` | Background fetcher submits to Substrate mempool | +| Both | Both flags set | Both paths run simultaneously | + +## 4. Other considerations + +- If the external mempool takes longer than the configured timeout (default: 1000ms) to respond, the transactions will be ignored for that cycle. - If the JSON format returned by the external private mempool is incorrect, the transactions will be ignored. - It is essential that, to be processed as a Zero Gas Transaction, the `gasPriceLimit` parameter is set to 0. +- In enqueue mode, transactions are deduplicated by their Ethereum transaction hash. Duplicate transactions seen within the last 25 blocks are skipped. +- Zero-gas transactions submitted to the Substrate pool have `priority = u64::MAX`, ensuring they are included before regular transactions. -## 4. Diagram +## 5. CLI Reference + +| Flag | Description | Default | +|------|-------------|---------| +| `--zero-gas-tx-pool ` | HTTP URL for inline fetch during block proposal | _(none)_ | +| `--zero-gas-tx-pool-timeout ` | HTTP timeout for both modes | `1000` | +| `--zero-gas-tx-pool-enqueue ` | HTTP URL for background enqueue mode | _(none)_ | +| `--zero-gas-tx-pool-enqueue-interval ` | Poll interval for enqueue mode | `3000` | + +## 6. Diagrams + +### Inline Mode (Original) ```mermaid graph TD; @@ -55,3 +140,35 @@ graph TD; H -->|Sponsored Transactions| C C -->|Include Zero Gas Transactions| F ``` + +### Enqueue Mode (Background Fetcher) + +```mermaid +graph TD; + A[User] -->|Create Transaction| B[External ZGT Pool] + B -->|POST HTTP Response| C[Background Fetcher Worker] + C -->|submit_one, TransactionSource::Local| D[Substrate Transaction Pool] + D -->|pool.ready| E[Block Proposer] + E -->|block_builder.push| F[Blockchain] + G[Regular Transactions] -->|Public Mempool| D + H[Sponsored Transactions] -->|Public Mempool| D + + style C fill:#e1f5fe + style D fill:#fff3e0 +``` + +### Combined Architecture + +```mermaid +graph TD; + A[External ZGT Pool A] -->|Inline fetch during proposal| B[Block Proposer] + C[External ZGT Pool B] -->|Background poll every ~3s| D[ZGT Fetcher Worker] + D -->|Submit to pool| E[Substrate TX Pool] + E -->|pool.ready| B + F[Regular Transactions] --> E + B -->|Build block| G[Blockchain] + + style D fill:#e1f5fe + style E fill:#fff3e0 + style B fill:#e8f5e9 +``` diff --git a/node/Cargo.toml b/node/Cargo.toml index 80a43c0..e32fdf3 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -88,6 +88,7 @@ stability-runtime = { path = "./../runtime", features = ["std"] } # Stability stbl-cli-authorship = { workspace = true } +stbl-zero-gas-fetcher = { workspace = true } stbl-primitives-fee-compatible-api = { workspace = true, features = [ "default", ] } diff --git a/node/src/service.rs b/node/src/service.rs index d55f5dd..6f2197d 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -606,6 +606,22 @@ where telemetry.as_ref().map(|x| x.handle()), ); + // Spawn the background ZGT fetcher if the enqueue URL is configured. + // This polls the external pool and submits ZGTs into the Substrate mempool, + // decoupling the HTTP fetch from the block proposal hot path. + if let Some(ref zgt_enqueue_url) = stability_config.zero_gas_tx_pool_enqueue { + stbl_zero_gas_fetcher::spawn_zero_gas_fetcher::( + task_manager.spawn_handle(), + client.clone(), + transaction_pool.clone(), + keystore_container.keystore(), + zgt_enqueue_url.clone(), + stability_config.zero_gas_tx_pool_timeout, + stability_config.zero_gas_tx_pool_enqueue_interval, + prometheus_registry.as_ref(), + ); + } + let slot_duration = sc_consensus_aura::slot_duration(&*client)?; let target_gas_price = eth_config.target_gas_price; let create_inherent_data_providers = move |_, ()| async move { @@ -728,6 +744,7 @@ where RA: Send + Sync + 'static, RA::RuntimeApi: RuntimeApiCollection, HF: HostFunctionsT + 'static, + NumberFor: Into, { let proposer_factory = stbl_cli_authorship::ProposerFactory::new( task_manager.spawn_handle(), @@ -740,6 +757,20 @@ where telemetry.as_ref().map(|x| x.handle()), ); + // Spawn the background ZGT fetcher for manual seal mode too + if let Some(ref zgt_enqueue_url) = stability_config.zero_gas_tx_pool_enqueue { + stbl_zero_gas_fetcher::spawn_zero_gas_fetcher::( + task_manager.spawn_handle(), + client.clone(), + transaction_pool.clone(), + keystore.keystore(), + zgt_enqueue_url.clone(), + stability_config.zero_gas_tx_pool_timeout, + stability_config.zero_gas_tx_pool_enqueue_interval, + prometheus_registry, + ); + } + thread_local!(static TIMESTAMP: RefCell = const { RefCell::new(0) }); /// Provide a mock duration starting at 0 in millisecond for timestamp inherent. diff --git a/node/src/stability.rs b/node/src/stability.rs index 673c8db..4ec07cf 100644 --- a/node/src/stability.rs +++ b/node/src/stability.rs @@ -31,6 +31,7 @@ use std::{marker::PhantomData, sync::Arc}; #[derive(Clone, Debug, clap::Parser)] pub struct StabilityConfiguration { /// HTTP URL of the private pool from which the node will retrieve zero-gas transactions + /// inline during block proposal (existing behavior). #[arg(long, value_name = "URL")] pub zero_gas_tx_pool: Option, @@ -38,6 +39,17 @@ pub struct StabilityConfiguration { /// (default: 1000) #[arg(long, value_name = "MILLISECONDS", default_value = "1000")] pub zero_gas_tx_pool_timeout: u64, + + /// HTTP URL of the pool from which a background worker will fetch zero-gas + /// transactions and enqueue them into the Substrate mempool. This decouples + /// the HTTP fetch from the block proposal hot path. + #[arg(long, value_name = "URL")] + pub zero_gas_tx_pool_enqueue: Option, + + /// Poll interval in milliseconds for the zero-gas transaction enqueue pool + /// (default: 3000) + #[arg(long, value_name = "MILLISECONDS", default_value = "3000")] + pub zero_gas_tx_pool_enqueue_interval: u64, } /// StbleAuraConsensusDataProvider provides the data required for the Aura consensus engine. diff --git a/pallets/zero-gas-transactions/src/lib.rs b/pallets/zero-gas-transactions/src/lib.rs index d58938a..6e2741c 100644 --- a/pallets/zero-gas-transactions/src/lib.rs +++ b/pallets/zero-gas-transactions/src/lib.rs @@ -102,7 +102,7 @@ pub mod pallet { { type Call = Call; - fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { + fn validate_unsigned(source: TransactionSource, call: &Self::Call) -> TransactionValidity { match call { Call::send_zero_gas_transaction { transaction, @@ -113,13 +113,22 @@ pub mod pallet { TransactionValidityError::Invalid(InvalidTransaction::BadProof) })?; - let current_block_validator = >::find_author(); - - Self::ensure_zero_gas_transaction( - current_block_validator, - validator_signature.clone(), - ) - .map_err(|_| TransactionValidityError::Invalid(InvalidTransaction::BadProof))?; + // For InBlock source (block import/execution), perform full consent + // signature validation. For Local/External sources (pool submission), + // skip the consent check since block_number() and find_author() return + // incorrect values during pool validation. The consent signature will + // be fully validated during block execution in send_zero_gas_transaction. + if matches!(source, TransactionSource::InBlock) { + let current_block_validator = >::find_author(); + + Self::ensure_zero_gas_transaction( + current_block_validator, + validator_signature.clone(), + ) + .map_err(|_| { + TransactionValidityError::Invalid(InvalidTransaction::BadProof) + })?; + } let transaction_data: TransactionData = transaction.into(); @@ -129,6 +138,7 @@ pub mod pallet { return sp_runtime::transaction_validity::ValidTransactionBuilder::default() .and_provides((from, transaction_data.nonce)) .priority(u64::MAX) + .longevity(20) .build(); } _ => Err(TransactionValidityError::Unknown( @@ -281,6 +291,12 @@ pub mod pallet { Ok(()) } + /// The number of blocks before and after the current block for which + /// a validator consent signature is considered valid. This allows the + /// background ZGT fetcher to sign for a future block and have the + /// signature remain valid when the transaction is actually included. + const CONSENT_BLOCK_WINDOW: u64 = 10; + fn ensure_zero_gas_transaction( expected_validator: H160, validator_signature: Vec, @@ -290,19 +306,42 @@ pub mod pallet { frame_system::Pallet::::block_number(), ); + // Try the exact block number first (fast path for the inline authorship case) let zero_gas_trx_internal_message: Vec = Self::get_zero_gas_transaction_signing_message(block_number.into(), chain_id); - let eip191_message = stbl_tools::eth::build_eip191_message_hash(zero_gas_trx_internal_message); - let zero_gas_trx_signer_address = Self::get_zero_gas_trx_signer(validator_signature.clone(), eip191_message.clone()); - match zero_gas_trx_signer_address { - Some(address) if address == expected_validator => Ok(()), - _ => Err(()), + if matches!(zero_gas_trx_signer_address, Some(address) if address == expected_validator) + { + return Ok(()); + } + + // If exact block didn't match, try a +/- CONSENT_BLOCK_WINDOW range. + // This supports the pool-based enqueue path where the background fetcher + // signs for best_block + 1 but the transaction may execute several blocks later. + let range_start = block_number.saturating_sub(Self::CONSENT_BLOCK_WINDOW); + let range_end = block_number.saturating_add(Self::CONSENT_BLOCK_WINDOW); + + for candidate_block in range_start..=range_end { + if candidate_block == block_number { + continue; // already tried above + } + + let message = + Self::get_zero_gas_transaction_signing_message(candidate_block, chain_id); + let eip191 = stbl_tools::eth::build_eip191_message_hash(message); + let signer = + Self::get_zero_gas_trx_signer(validator_signature.clone(), eip191.clone()); + + if matches!(signer, Some(address) if address == expected_validator) { + return Ok(()); + } } + + Err(()) } pub fn get_zero_gas_transaction_signing_message( diff --git a/pallets/zero-gas-transactions/src/mock.rs b/pallets/zero-gas-transactions/src/mock.rs index ce6f4fd..2718810 100644 --- a/pallets/zero-gas-transactions/src/mock.rs +++ b/pallets/zero-gas-transactions/src/mock.rs @@ -26,9 +26,9 @@ use runner::Runner as StabilityRunner; use ethereum::{TransactionAction, TransactionSignature}; use frame_support::{ construct_runtime, - pallet_prelude::{StorageValue, ValueQuery}, + pallet_prelude::{OptionQuery, StorageValue, ValueQuery}, parameter_types, - traits::{Everything, StorageInstance}, + traits::{Everything, FindAuthor, StorageInstance}, weights::Weight, }; use pallet_evm::{EnsureAddressNever, EnsureAddressRoot}; @@ -37,7 +37,7 @@ use sp_core::{keccak_256, H160, H256, U256}; use sp_runtime::BuildStorage; use sp_runtime::{ traits::{BlakeTwo256, ConstU32, IdentifyAccount, IdentityLookup, Verify}, - MultiSignature, + ConsensusEngineId, MultiSignature, }; use std::collections::BTreeMap; @@ -214,7 +214,7 @@ impl pallet_evm::Config for Runtime { type OnChargeTransaction = (); type BlockGasLimit = BlockGasLimit; type BlockHashMapping = pallet_evm::SubstrateBlockHashMapping; - type FindAuthor = (); + type FindAuthor = MockFindAuthor; type OnCreate = (); type GasLimitPovSizeRatio = GasLimitPovSizeRatio; type Timestamp = Timestamp; @@ -270,6 +270,29 @@ impl pallet_erc20_manager::ERC20Manager for MockERC20Manager { } } +// Storage-backed mock FindAuthor for pallet_evm. +// Set the block author in tests via MockBlockAuthor::put(address). +pub struct MockAuthorPrefix; +impl StorageInstance for MockAuthorPrefix { + fn pallet_prefix() -> &'static str { + "MockAuthorPrefix" + } + + const STORAGE_PREFIX: &'static str = "Author"; +} + +pub type MockBlockAuthor = StorageValue; + +pub struct MockFindAuthor; +impl FindAuthor for MockFindAuthor { + fn find_author<'a, I>(_digests: I) -> Option + where + I: 'a + IntoIterator, + { + MockBlockAuthor::get() + } +} + pub struct LegacyUnsignedTransaction { pub nonce: U256, pub gas_price: U256, @@ -394,3 +417,31 @@ pub fn new_test_ext() -> sp_io::TestExternalities { t.into() } + +/// Derive an Ethereum H160 address from a private key. +pub fn eth_address_from_private_key(private_key: &H256) -> H160 { + let secret = libsecp256k1::SecretKey::parse_slice(&private_key[..]).unwrap(); + let public = libsecp256k1::PublicKey::from_secret_key(&secret); + let public_bytes = public.serialize(); + // Skip the 0x04 prefix byte (uncompressed public key format) + let hash = keccak_256(&public_bytes[1..65]); + H160::from_slice(&hash[12..32]) +} + +/// Sign a validator consent message correctly using EIP-191 format, +/// matching the pallet's `ensure_zero_gas_transaction` verification logic. +/// +/// Returns a 65-byte signature (r[32] + s[32] + recovery_id[1]) compatible +/// with `sp_io::crypto::secp256k1_ecdsa_recover`. +pub fn sign_consent_message(private_key: &H256, block_number: u64, chain_id: u64) -> Vec { + let message = + crate::Pallet::::get_zero_gas_transaction_signing_message(block_number, chain_id); + let eip191_hash = stbl_tools::eth::build_eip191_message_hash(message); + let msg = libsecp256k1::Message::parse(eip191_hash.as_fixed_bytes()); + let secret = libsecp256k1::SecretKey::parse_slice(&private_key[..]).unwrap(); + let (sig, recovery_id) = libsecp256k1::sign(&msg, &secret); + let mut signature = [0u8; 65]; + signature[0..64].copy_from_slice(&sig.serialize()); + signature[64] = recovery_id.serialize(); + signature.to_vec() +} diff --git a/pallets/zero-gas-transactions/src/tests.rs b/pallets/zero-gas-transactions/src/tests.rs index 86b9cd9..01a2c22 100644 --- a/pallets/zero-gas-transactions/src/tests.rs +++ b/pallets/zero-gas-transactions/src/tests.rs @@ -1,12 +1,12 @@ -// Copyright © 2022 STABILITY SOLUTIONS, INC. (“STABILITY”) +// Copyright © 2022 STABILITY SOLUTIONS, INC. ("STABILITY") // This file is part of the Stability Global Trust Network client -// software and accompanying documentation (the “Software”). +// software and accompanying documentation (the "Software"). // You can download and use the Software for free under the terms of // the Stability Open License Agreement as published by Stability on // Github at https://github.com/stabilityprotocol/stability/blob/master/LICENSE. -// THE SOFTWARE IS PROVIDED “AS IS” WITHOUT WARRANTY OF ANY KIND. +// THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. // STABILITY EXPRESSLY DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, // INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND // NON-INFRINGEMENT. IN NO EVENT SHALL OWNER BE LIABLE FOR ANY @@ -17,10 +17,19 @@ // Please see the Stability Open License Agreement for more // information. -use crate::mock::{legacy_erc20_creation_transaction, new_test_ext, ChainId, Runtime, System}; +use crate::mock::{ + eth_address_from_private_key, legacy_erc20_creation_transaction, new_test_ext, + sign_consent_message, ChainId, MockBlockAuthor, Runtime, System, +}; +use frame_support::pallet_prelude::{TransactionSource, ValidateUnsigned}; use frame_system::RawOrigin; use pallet_ethereum::Transaction; use sp_core::{ecdsa, hexdisplay::AsBytesRef, Pair, H256}; +use sp_runtime::transaction_validity::{InvalidTransaction, TransactionValidityError}; + +// ============================================================================ +// Existing test (preserved) +// ============================================================================ #[test] fn fail_to_execute_transaction_with_high_nonce() { @@ -56,3 +65,480 @@ fn fail_to_execute_transaction_with_high_nonce() { )); }) } + +// ============================================================================ +// Group 1: validate_unsigned with TransactionSource-based branching +// ============================================================================ + +/// Test that `TransactionSource::Local` (pool submission) skips the validator +/// consent signature check. This is the core enabler for the mempool enqueue path: +/// the background fetcher submits ZGTs with TransactionSource::Local, and pool +/// validation should pass even without a valid consent signature for the current block. +#[test] +fn validate_unsigned_local_source_skips_consent_check() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(1); + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + + // Use a garbage consent signature — Local source should skip the check + let garbage_signature = vec![0u8; 65]; + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: garbage_signature, + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::Local, &call); + + assert!( + result.is_ok(), + "TransactionSource::Local should skip consent check, but got: {:?}", + result.err() + ); + }) +} + +/// Test that `TransactionSource::External` (pool submission from gossip) +/// also skips the consent check, same as Local. +#[test] +fn validate_unsigned_external_source_skips_consent_check() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(2); + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + + let garbage_signature = vec![0u8; 65]; + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: garbage_signature, + }; + + let result = + crate::Pallet::::validate_unsigned(TransactionSource::External, &call); + + assert!( + result.is_ok(), + "TransactionSource::External should skip consent check, but got: {:?}", + result.err() + ); + }) +} + +/// Test that `TransactionSource::InBlock` (block execution) REQUIRES a valid +/// consent signature. With a wrong signature, it should fail with BadProof. +#[test] +fn validate_unsigned_inblock_source_requires_valid_consent() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(3); + let validator_key = H256::from_low_u64_be(100); + let validator_address = eth_address_from_private_key(&validator_key); + + // Set the block author so find_author() returns our validator + MockBlockAuthor::put(validator_address); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + + // Use a garbage consent signature — InBlock source should reject it + let garbage_signature = vec![0u8; 65]; + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: garbage_signature, + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::InBlock, &call); + + assert!( + result.is_err(), + "TransactionSource::InBlock should require valid consent signature" + ); + assert_eq!( + result.unwrap_err(), + TransactionValidityError::Invalid(InvalidTransaction::BadProof) + ); + }) +} + +/// Test that `TransactionSource::InBlock` succeeds when given a properly +/// signed consent message matching the current block and validator. +#[test] +fn validate_unsigned_inblock_source_succeeds_with_valid_consent() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(4); + let validator_key = H256::from_low_u64_be(200); + let validator_address = eth_address_from_private_key(&validator_key); + + // Set the block author and block number + MockBlockAuthor::put(validator_address); + let block_number: u64 = 5; + frame_system::Pallet::::set_block_number(block_number); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, block_number, ChainId::get()); + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: consent_sig, + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::InBlock, &call); + + assert!( + result.is_ok(), + "InBlock with valid consent should succeed, but got: {:?}", + result.err() + ); + }) +} + +/// Test that even with `TransactionSource::Local`, a transaction with an +/// invalid Ethereum signature (unrecoverable) is still rejected. +/// The pool skip only applies to the *validator consent* check, NOT the +/// Ethereum transaction signature check. +#[test] +fn validate_unsigned_local_source_still_checks_tx_signature() { + new_test_ext().execute_with(|| { + // Create a legacy transaction with an irrecoverable signature. + // We set both r and s to zero which makes secp256k1 recovery impossible. + let trx = ethereum::LegacyTransaction { + nonce: sp_core::U256::zero(), + gas_price: sp_core::U256::zero(), + gas_limit: sp_core::U256::from(0x100000), + action: ethereum::TransactionAction::Call(sp_core::H160::zero()), + value: sp_core::U256::zero(), + input: vec![], + signature: ethereum::TransactionSignature::new( + // v = 27 (unprotected legacy), r and s = 1 (not a valid point on the curve + // for this message, so recovery will fail or produce a garbage address + // with no valid chain_id check) + 38, // chain_id * 2 + 35 = 20180428 * 2 + 35 = 40360891, but we need a simpler approach + H256::from_low_u64_be(1), // r = 1 (invalid for secp256k1 recovery for most messages) + H256::from_low_u64_be(1), // s = 1 + ) + .unwrap(), + }; + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: vec![0u8; 65], + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::Local, &call); + + // The transaction should fail at ensure_transaction_signature (BadProof) + // or at pool_ensure_transaction_unicity (Call) due to invalid chain_id. + // Either way, it must NOT succeed. + assert!( + result.is_err(), + "Transaction with invalid/unrecoverable signature should be rejected even for Local source" + ); + }) +} + +// ============================================================================ +// Group 2: ±10 Block Window in ensure_zero_gas_transaction +// ============================================================================ + +/// Test that the consent signature is verified successfully when it matches +/// the exact current block number (fast path). +#[test] +fn consent_signature_exact_block_succeeds() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(10); + let validator_key = H256::from_low_u64_be(300); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + let block_number: u64 = 42; + frame_system::Pallet::::set_block_number(block_number); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, block_number, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + // The transaction should pass consent check. It may fail at EVM execution + // (the mock doesn't have a real contract at Sponsor address) but it should + // NOT fail with "Invalid zero gas transaction signature". + if let Err(ref err) = result { + assert!( + !matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Consent signature for exact block should be valid" + ); + } + }) +} + +/// Test that the consent signature is verified successfully when signed for +/// a block within the ±10 window (the pool-based enqueue scenario). +/// The fetcher signs for best_block+1, but execution may happen several blocks later. +#[test] +fn consent_signature_within_window_succeeds() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(11); + let validator_key = H256::from_low_u64_be(400); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + + // The fetcher signed for block 50, but we're now executing at block 58 + // (within the ±10 window) + let signing_block: u64 = 50; + let executing_block: u64 = 58; + frame_system::Pallet::::set_block_number(executing_block); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, signing_block, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + if let Err(ref err) = result { + assert!( + !matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Consent signature within +8 block window should be valid" + ); + } + }) +} + +/// Test the ±10 window in the negative direction: signed for block 50, +/// executing at block 42 (within window). +#[test] +fn consent_signature_within_negative_window_succeeds() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(12); + let validator_key = H256::from_low_u64_be(500); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + + let signing_block: u64 = 50; + let executing_block: u64 = 42; + frame_system::Pallet::::set_block_number(executing_block); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, signing_block, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + if let Err(ref err) = result { + assert!( + !matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Consent signature within -8 block window should be valid" + ); + } + }) +} + +/// Test that a consent signature signed for a block OUTSIDE the ±10 window +/// is rejected. Signed for block 20, but executing at block 35 (15 blocks away). +#[test] +fn consent_signature_outside_window_fails() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(13); + let validator_key = H256::from_low_u64_be(600); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + + let signing_block: u64 = 20; + let executing_block: u64 = 35; // 15 blocks away, outside ±10 window + frame_system::Pallet::::set_block_number(executing_block); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, signing_block, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + assert!( + result.is_err(), + "Consent signature outside ±10 window should be rejected" + ); + + let err = result.unwrap_err(); + assert!( + matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Expected 'Invalid zero gas transaction signature' error, got: {:?}", + err.error + ); + }) +} + +/// Test the boundary: consent signed for block N, executing at exactly N+10 +/// (the edge of the window). Should succeed. +#[test] +fn consent_signature_at_window_boundary_succeeds() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(14); + let validator_key = H256::from_low_u64_be(700); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + + let signing_block: u64 = 50; + let executing_block: u64 = 60; // exactly +10 blocks = boundary + frame_system::Pallet::::set_block_number(executing_block); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, signing_block, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + if let Err(ref err) = result { + assert!( + !matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Consent signature at exactly +10 boundary should be valid" + ); + } + }) +} + +/// Test the boundary: consent signed for block N, executing at N+11 +/// (just past the window). Should fail. +#[test] +fn consent_signature_just_past_window_boundary_fails() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(15); + let validator_key = H256::from_low_u64_be(800); + let validator_address = eth_address_from_private_key(&validator_key); + + MockBlockAuthor::put(validator_address); + + let signing_block: u64 = 50; + let executing_block: u64 = 61; // +11 blocks, just past the window + frame_system::Pallet::::set_block_number(executing_block); + + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + let consent_sig = sign_consent_message(&validator_key, signing_block, ChainId::get()); + + let result = crate::Pallet::::send_zero_gas_transaction( + RawOrigin::None.into(), + Transaction::Legacy(trx), + consent_sig, + ); + + assert!( + result.is_err(), + "Consent signature at +11 (past window) should be rejected" + ); + + let err = result.unwrap_err(); + assert!( + matches!( + err.error, + sp_runtime::DispatchError::Other("Invalid zero gas transaction signature") + ), + "Expected 'Invalid zero gas transaction signature' error, got: {:?}", + err.error + ); + }) +} + +// ============================================================================ +// Group 3: ValidTransaction properties (longevity, provides, priority) +// ============================================================================ + +/// Test that validate_unsigned returns a ValidTransaction with longevity=20. +/// This ensures ZGTs expire from the pool after ~20 blocks. +#[test] +fn validate_unsigned_returns_correct_longevity() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(20); + let trx = legacy_erc20_creation_transaction(0.into(), &sender_key); + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: vec![0u8; 65], // Local source skips consent check + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::Local, &call); + + assert!(result.is_ok()); + let valid_tx = result.unwrap(); + assert_eq!( + valid_tx.longevity, 20, + "ZGT pool longevity should be 20 blocks, got: {}", + valid_tx.longevity + ); + }) +} + +/// Test that validate_unsigned returns the correct provides tag (sender, nonce) +/// and maximum priority. +#[test] +fn validate_unsigned_returns_correct_provides_and_priority() { + new_test_ext().execute_with(|| { + let sender_key = H256::from_low_u64_be(21); + let sender_address = eth_address_from_private_key(&sender_key); + let nonce = 0u64; + let trx = legacy_erc20_creation_transaction(nonce.into(), &sender_key); + + let call = crate::Call::::send_zero_gas_transaction { + transaction: Transaction::Legacy(trx), + validator_signature: vec![0u8; 65], + }; + + let result = crate::Pallet::::validate_unsigned(TransactionSource::Local, &call); + + assert!(result.is_ok()); + let valid_tx = result.unwrap(); + + // Priority should be u64::MAX for ZGTs + assert_eq!( + valid_tx.priority, + u64::MAX, + "ZGT priority should be u64::MAX" + ); + + // Provides should contain the (sender_address, nonce) tuple + assert!( + !valid_tx.provides.is_empty(), + "ValidTransaction should have provides tags" + ); + + // The provides tag is encoded as (H160, U256), verify it's present + use parity_scale_codec::Encode; + let expected_tag = (sender_address, sp_core::U256::from(nonce)).encode(); + assert!( + valid_tx.provides.contains(&expected_tag), + "Provides should contain (sender_address, nonce). Expected: {:?}, Got: {:?}", + expected_tag, + valid_tx.provides + ); + }) +}