Sapphire Testnet as a HONOR Deposit Source Chain

Branch rube/feature/sapphire-token-support · base 4754c55 (master) · PR #246

Overview

This PR lets users deposit and withdraw the HONOR token on Sapphire testnet (chain 23295), the same chain the Accounting contract lives on, through the existing deposit flow: get a deposit address, send tokens, the service verifies and sweeps them, the contract credits the balance. Withdrawals send HONOR back out. Everything that already works for Base Sepolia and Ethereum Sepolia now also works for 23295, gated behind configuration.

Four things beyond pure config were needed:

  • A gas-limit fix in the contract. A native transfer on Sapphire costs 22,140 gas, but the contract signed native sweeps and gas-funding transactions with a 21,000 limit, so both would always fail. The limit is raised to 25,000 and VERSION is bumped to 2 so the upgrade task actually performs the proxy upgrade (it silently skips when the on-chain version is not lower).
  • An RPC identity check. Nothing verified that an RPC endpoint actually reports the chain ID it is filed under. A mis-filed URL would verify deposits on one chain while signing transactions for another. At startup each endpoint must now answer eth_chainId correctly or that chain is not served at all (fail closed).
  • Withdrawal-safety rework. Duplicate-broadcast handling used to trust geth error strings; Sapphire says invalid nonce instead, and string matching can mistake "the nonce was burned by some other transaction" for "our withdrawal went out". The processor now computes the expected transaction hash itself and requires a chain receipt (or pending entry) for exactly that hash before treating anything as already-paid. A per-chain readiness gate also refuses withdrawals when the contract's nonce bookkeeping disagrees with the signer's on-chain nonce, and the balance check for admitting withdrawals is now derived from the same gas price × gas limit values used for signing instead of one global constant that passed ~1000× too early on a 100-gwei chain.
  • Accurate asset advertisement. /deposits/address used to advertise a native minimum for every configured chain, so clients would be told native ROSE deposits work on 23295 when they don't. Advertised asset types now follow what is actually registered per chain: 23295 shows the HONOR ERC-20 minimum only.

Alongside: chain configuration entries for 23295 (and a 23293 mirror for the localnet dev loop), a deterministic localnet ERC-20 so the same-chain path is exercised in daily development, extensive tests (unit, lifespan, same-chain E2E, hardhat), and and the full flow was rehearsed live on a throwaway testnet deployment before this PR.

Rehearsed end-to-end on live testnet: an isolated app and contract ran the complete flow, including boot, deposit, sweep, credit, withdraw, a deliberate front-run of a withdrawal, and a mid-flight machine restart. All green. Details in Test evidence.

Security

New and changed assumptions

AreaAssumption after this PR
RPC endpointsNew check, fail-closed. Each endpoint in chain_rpc_urls must report its filed chain ID via eth_chainId at startup. Mismatch or unreachable ⇒ the chain is excluded from the served set (never mis-served). Cross-chain identity confusion is no longer possible via a mis-filed URL.
Duplicate withdrawal broadcastsWeaker trust in node errors. "Already broadcast" is now proven by a receipt (or exact pending transaction) for the hash computed from our own signed bytes; error-string matching is gone. A nonce burned by an unrelated transaction can no longer be misread as a paid withdrawal. The withdrawal stays pending and the high-water mark does not advance.
Per-chain nonce readinessNew gate. Withdrawals for a chain are refused while nonces(chain) on the contract is behind the signer's pending nonce on that chain. This is the main hazard when the accounting chain also becomes a source chain; it fails closed with a CRITICAL log.
Withdrawal admissionRequired balance is derived from the chain's gas price × the contract's own withdraw gas limits (+20% buffer, amount for native) instead of the old global 1e13-wei constant. Gas limits are read from the contract getters, with no mirrored constants in Python. MIN_WITHDRAWAL_GAS_BALANCE remains only as an extra floor.
Asset advertisementClients are told an asset type is supported only if a token of that type is registered for the chain. Native ROSE deposits on 23295 are no longer advertised (they were never supported, and registration would be irreversible).
Contract constantgasLimitNativeSweep 21,000 → 25,000 affects native sweeps and the gas-funding leg of every ERC-20 sweep. Unused gas is refunded; existing chains' gas_funding_amount_wei all cover 25,000 × their gas price (asserted in tests). No storage layout change; __gap untouched.
Upgrade gatingVERSION = 2 makes the upgrade task act instead of silently skipping. The skip-on-equal-version behaviour is now covered by tests in both directions.
Permissionless resolveWithdrawalUnchanged by this PR, but it matters more now that the chain is also a deposit source: anyone can fetch the signed withdrawal bytes via eth_call and broadcast them. The exact-hash verification path is what makes that safe, because the processor settles on the front-runner's receipt instead of misreading the nonce error. Verified live (see Test evidence).
Deposit-address reuse across chainsAccepted as-is (decision recorded 2026-08-17): derivation omits chainId, so a user's deposit address is identical across chains. No replay hazard (EIP-155), correlation accepted since addresses are transient sweep points.

Explicitly not new attack surface

  • resolveWithdrawal was already permissionless before this PR; the front-run probe exercised an existing, intended property.
  • SIWE domain allow-listing semantics are unchanged: the check is on the signed message domain, not headers.

Test evidence

Static gates (full CI list, green)

format-check · lint · typecheck · solidity-build · bytecode-size budget (27,859 / 64,512) · validate-upgrade · ABI presence · OpenAPI drift · pytest.

Automated suites

  • Python: 694 passed, 0 failed (includes the new same-chain E2E, lifespan, rpc-identity, and withdrawal suites).
  • Hardhat in-process: 98 passed / 4 pending.
  • Hardhat against sapphire-localnet: 101 passed, 0 failing.

Live rehearsals on Sapphire testnet (isolated throwaway deployments)

  • Upgrade rehearsal: fresh V1 proxy from the base commit → ran the real hardhat upgradeVERSION 1→2, sweep limit 21,000→25,000, owner/evmAddress/gasTank/siweAuth preserved, re-run correctly prints "Skipping upgrade" (version gate both ways).
  • Full deployment rehearsal (separate app, separate contract, rented machine): all six boot checks green (3-chain RPC identity, auth-key sync, ROFL signer published, HONOR registered, 100 gwei published, nonce readiness 0==0); /deposits/address advertises HONOR-only for 23295; 2-HONOR deposit verified → gas-funded (25k) → swept (cold slot) → credited; mid-flight machine restart recovered the sweep from persistent state and completed the credit, JWT survived; /deposits/pending scanned 639 blocks with no range error; withdrawal happy path paid out.
  • Front-run probe: signed withdrawal bytes obtained via permissionless eth_call and broadcast externally; the node rejected the processor's re-broadcast with invalid nonce, and the processor settled correctly on the exact expected hash. One payment, one balance debit.
Scope limits: the restart test landed after the sweep transaction had already mined, so the live-proven leg is broadcast→credit recovery. The earlier GAS_FUNDED→sweep-broadcast resume path is unit-tested only. The withdrawal admission threshold was not live-proven either (test funds sit far above any threshold); it is covered by unit tests.

Open questions

  • Per-token deposit floors. The ERC-20 minimum is per chain and assumes 18 decimals (HONOR is the only registered token). Registering a second token with different decimals on the same chain is the revisit trigger for per-token floors. Deferred deliberately.
  • Per-chain gas limits mapping. A single raised constant covers all chains today; the per-chain mapping (and its upgrade-ordering hazards) is deferred until a chain actually needs different values.
  • Proxy owner shape for the live upgrade. If the production proxy's owner is a Safe, the upgrade needs --output-safe (two Safe transactions); direct-EOA path was rehearsed. Confirm before executing.
  • GAS_FUNDED resume, live. Optional: re-run the restart test on a throwaway deployment with a tighter restart window to prove the earlier resume state on-chain as well as in unit tests.
  • Gas-price headroom. gas_funding_amount_wei on 23295 is sized for exactly 65,000 × 100 gwei with zero headroom; observed price was exactly 100 gwei. If Sapphire testnet's price rises, sweeps fail with insufficient balance to pay fees. Decide whether to size with a buffer.

Deployment: open steps for live

  1. PR approval & merge (this branch).
  2. Contract upgrade: cd solidity && npx hardhat upgrade --network sapphire-testnet --address 0x910CFfe4e8B27bc367F6E34D6D8e3C31DED68B6f; add --output-safe if the owner is a Safe. Verify VERSION() == 2 and gasLimitNativeSweep() == 25000 afterwards. Must land before the ROFL redeploy (signing with the old limit fails every 23295 sweep).
  3. Image: tag privana/v1.0.3-testnet → ci-docker builds and pushes the live image.
  4. Compose: digest the new image into compose.testnet.yaml, commit.
  5. ROFL: bump version: in rofl.yaml, oasis rofl build, commit, then oasis rofl deploy --deployment testnet (admin ptrus_testnet).
  6. Live verification: confirm the live app's boot logs show 23295 verified/registered; fund checks for evmAddress() and gasTankAddress() (gas tank signs every ERC-20 sweep's funding leg and has no auto top-up); obtain HONOR from the sole holder for testing; run deposit → check → sweep → credit and a withdrawal round-trip; exercise /deposits/pending?chain_id=23295.
  7. Token registration is last: it happens automatically at boot from ACCOUNTING_TOKEN_INFO, it is on-chain and not removable, which is why gas price, upgrade, RPC identity, and nonce readiness are all confirmed first (all four were validated in a live rehearsal).

Changed files: Config & API

+44 −13src/config/__init__.py
  • _build_chain_rpc_urls now always seeds the Sapphire RPC (from SAPPHIRE_CHAIN_ID/SAPPHIRE_RPC_URL) even when no Alchemy key is configured; Alchemy chains layer on top. Previously the function returned an empty map without a key.
  • Metadata dicts (CHAIN_NAMES, NATIVE_TOKEN_SYMBOLS, NATIVE_TOKEN_NAMES, NATIVE_TOKEN_DECIMALS) gain the 23295 entries ("Sapphire Testnet", ROSE, Rose, 18).
Show diff · +44 −13
--- a/src/config/__init__.py+++ b/src/config/__init__.py@@ -28,21 +28,25 @@ ALCHEMY_CHAIN_SUBDOMAINS: Dict[int, str] = {1 }2 3 CHAIN_NAMES: Dict[int, str] = {4    23295: "Sapphire Testnet",5     84532: "Base Sepolia",6     11155111: "Ethereum Sepolia",7 }8 9 NATIVE_TOKEN_SYMBOLS: Dict[int, str] = {10    23295: "ROSE",11     84532: "ETH",12     11155111: "ETH",13 }14 15 NATIVE_TOKEN_NAMES: Dict[int, str] = {16    23295: "Rose",17     84532: "Ether",18     11155111: "Ether",19 }20 21 NATIVE_TOKEN_DECIMALS: Dict[int, int] = {22    23295: 18,23     84532: 18,24     11155111: 18,25 }@@ -96,15 +100,39 @@ def _get_bool(name: str) -> bool:1     raise ValueError(f"Environment variable {name} must be a boolean")2 3 4def _build_chain_rpc_urls(alchemy_api_key: Optional[str]) -> Dict[int, str]:5def _build_chain_rpc_urls(6    alchemy_api_key: Optional[str],7    sapphire_chain_id: Optional[int] = None,8    sapphire_rpc_url: Optional[str] = None,9) -> Dict[int, str]:10    """Map chain ID to RPC URL.1112    Sapphire is seeded from its own env vars before the Alchemy key is checked, so a13    deployment without Alchemy still serves Sapphire.14    """15    rpc_urls: Dict[int, str] = {}1617    if sapphire_chain_id is None:18        raw_chain_id = os.getenv("SAPPHIRE_CHAIN_ID")19        if raw_chain_id:20            try:21                sapphire_chain_id = int(raw_chain_id, 0)22            except ValueError:23                logging.error("SAPPHIRE_CHAIN_ID is invalid; cannot seed Sapphire RPC")2425    if sapphire_rpc_url is None:26        sapphire_rpc_url = os.getenv("SAPPHIRE_RPC_URL")2728    if sapphire_chain_id and sapphire_rpc_url:29        rpc_urls[sapphire_chain_id] = sapphire_rpc_url3031     if not alchemy_api_key or alchemy_api_key == "your-alchemy-api-key-here":32         logging.warning(33            "ALCHEMY_API_KEY not configured. Deposit verification will fail. "34            "ALCHEMY_API_KEY not configured. Deposit verification will fail for Alchemy chains. "35             "Get an API key from https://dashboard.alchemy.com/"36         )37        return {}38        return rpc_urls39 40    rpc_urls = {}41     for chain_id, subdomain in ALCHEMY_CHAIN_SUBDOMAINS.items():42         rpc_urls[chain_id] = f"https://{subdomain}.g.alchemy.com/v2/{alchemy_api_key}"43 @@ -191,8 +219,14 @@ def load_settings(refresh: bool = False) -> Settings:1 2     global _settings3     if _settings is None or refresh:4        sapphire_chain_id = _get_int("SAPPHIRE_CHAIN_ID")5        sapphire_rpc_url = os.getenv("SAPPHIRE_RPC_URL")6         alchemy_api_key = os.getenv("ALCHEMY_API_KEY")7        chain_rpc_urls = _build_chain_rpc_urls(alchemy_api_key)8        chain_rpc_urls = _build_chain_rpc_urls(9            alchemy_api_key,10            sapphire_chain_id=sapphire_chain_id,11            sapphire_rpc_url=sapphire_rpc_url,12        )13         auth_token_storage_dir = os.getenv("AUTH_TOKEN_STORAGE_DIR", ".auth_tokens")14         onramp_provider = os.getenv("ONRAMP_PROVIDER")15 
+28 −11src/config/chain_config.py
  • Two new CHAIN_CONFIGS entries: 23295 (Sapphire testnet) and 23293 (sapphire-localnet mirror, so the dev loop exercises the same-chain path). Values: scan chunks of 100 blocks (gateway cap), ~1h lookback / ~6h clamp for the ~5.7s block time, finality depth 2, 1-HONOR ERC-20 minimum, gas funding of 6.5e15 wei = 65,000 gas × 100 gwei (the EVM debits gas limit × price upfront).
  • Deleted the dead constants SWEEP_GAS_LIMIT_NATIVE/ERC20 and GAS_FUNDING_GAS_LIMIT. They were imported nowhere, and they read as the source of truth for numbers that actually live in the contract.
Show diff · +28 −11
--- a/src/config/chain_config.py+++ b/src/config/chain_config.py@@ -51,6 +51,29 @@ class ChainConfig:1 # ─── Chain definitions (single source of truth) ────────────────────────2 3 CHAIN_CONFIGS: Dict[int, ChainConfig] = {4    23295: ChainConfig(5        chain_id=23295,6        finality_depth=2,  # Sapphire Testnet7        min_deposit_native_wei=10_000_000_000_000_000,  # 0.01 ROSE8        min_deposit_erc20_wei=1_000_000_000_000_000_000,  # 1 HONOR (18 decimals)9        # The EVM debits gasLimit x gasPrice upfront, so fund the full 65k x 100 gwei10        gas_funding_amount_wei=6_500_000_000_000_000,  # 0.0065 ROSE11        l2_type=L2Type.NONE,12        discovery_scan_chunk_blocks=100,  # Sapphire gateway caps eth_getLogs at 100 blocks13        discovery_lookback_blocks=640,  # ~1h at ~5.7s blocks14        discovery_max_lookback_blocks=3_800,  # ~6h at ~5.7s blocks15    ),16    23293: ChainConfig(17        chain_id=23293,  # sapphire-localnet dev-harness mirror18        finality_depth=2,19        min_deposit_native_wei=10_000_000_000_000_000,  # 0.01 ROSE20        min_deposit_erc20_wei=1_000_000_000_000_000_000,  # 1 HONOR (18 decimals)21        gas_funding_amount_wei=6_500_000_000_000_000,  # 0.0065 ROSE (65k gas * 100 gwei)22        l2_type=L2Type.NONE,23        discovery_scan_chunk_blocks=100,  # match Sapphire gateway log cap24        discovery_lookback_blocks=640,  # ~1h at ~5.7s blocks25        discovery_max_lookback_blocks=3_800,  # ~6h at ~5.7s blocks26    ),27     84532: ChainConfig(28         chain_id=84532,29         finality_depth=15,  # Base Sepolia (OP Stack)@@ -72,11 +95,6 @@ CHAIN_CONFIGS: Dict[int, ChainConfig] = {1 2 DEFAULT_FINALITY_DEPTH = 323 4# Gas limits for sweep transactions (chain-independent)5SWEEP_GAS_LIMIT_NATIVE = 21_0006SWEEP_GAS_LIMIT_ERC20 = 65_0007GAS_FUNDING_GAS_LIMIT = 21_00089 # ERC20 Transfer event topic10 TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"11 
+28 −6src/api/routes.py
  • POST /deposits/address: the min_deposit map now includes an asset type only when a token of that type is registered for the chain (derived from settings.token_infos). 23295 advertises erc20 only; 84532 still advertises both. This stops clients from being told native ROSE deposits work on 23295 when they'd hit a dead end after sending funds.
Show diff · +28 −6
--- a/src/api/routes.py+++ b/src/api/routes.py@@ -409,20 +409,28 @@ async def get_deposit_address(1         address = await _service.get_deposit_address(2             payload.chain_type, payload.version, auth.token3         )4        settings = load_settings()5        native_supported = {6            entry["chain_id"] for entry in settings.token_infos if not entry.get("token_address")7        }8        erc20_supported = {9            entry["chain_id"] for entry in settings.token_infos if entry.get("token_address")10        }11        min_deposit: dict[str, dict[str, str]] = {}12        for cid in MIN_DEPOSIT_NATIVE_WEI:13            chain_min: dict[str, str] = {}14            if cid in native_supported:15                chain_min["native"] = str(MIN_DEPOSIT_NATIVE_WEI.get(cid, 0))16            if cid in erc20_supported:17                chain_min["erc20"] = str(MIN_DEPOSIT_ERC20_WEI.get(cid, 0))18            min_deposit[str(cid)] = chain_min1920         return DepositAddressResponse(21             deposit_address=address,22             chain_type=payload.chain_type,23             version=payload.version,24            min_deposit={25                str(cid): {26                    "native": str(MIN_DEPOSIT_NATIVE_WEI.get(cid, 0)),27                    "erc20": str(MIN_DEPOSIT_ERC20_WEI.get(cid, 0)),28                }29                for cid in MIN_DEPOSIT_NATIVE_WEI30            },31            finality_depth={32                str(cid): get_finality_depth(cid) for cid in load_settings().chain_rpc_urls33            },34            min_deposit=min_deposit,35            finality_depth={str(cid): get_finality_depth(cid) for cid in settings.chain_rpc_urls},36         )37     except ContractLogicError as exc:38         if "Siwe" in str(exc) or "InvalidSiwe" in str(exc):
+5 −1src/models/accounting.py
  • DepositAddressResponse.min_deposit documented/typed as omitting unsupported asset keys rather than always containing both, matching the routes change.
Show diff · +5 −1
--- a/src/models/accounting.py+++ b/src/models/accounting.py@@ -551,8 +551,9 @@ class DepositAddressResponse(BaseModel):1     chain_type: Literal["evm"]2     version: int3     min_deposit: dict[str, dict[str, str]] = Field(4        default_factory=dict5    )  # {chain_id: {native, erc20}}6        default_factory=dict,7        description="Minimum deposit amounts per chain for supported asset types ('native', 'erc20')",8    )  # {chain_id: {asset_type: min_amount_wei}}9     finality_depth: dict[str, int] = Field(10         default_factory=dict,11         description="The number of blocks on the target chain before the transaction can be assumed to be final",
+7src/main.py
  • Lifespan now runs the RPC identity verification first, before any service starts consuming chain_rpc_urls.
Show diff · +7 −0
--- a/src/main.py+++ b/src/main.py@@ -23,6 +23,7 @@ from src.services.deposit_processor import get_deposit_processor1 from src.services.gas_price_bootstrap import bootstrap_gas_prices2 from src.services.onramp_intent import get_onramp_intent_key_manager3 from src.services.rofl_signer_bootstrap import bootstrap_rofl_signer_address4from src.services.rpc_identity import initialize_verified_chain_rpc_urls5 from src.services.token_info_bootstrap import bootstrap_token_info6 from src.services.withdrawal_processor import get_withdrawal_processor7 @@ -116,6 +117,12 @@ async def lifespan(_app: FastAPI):1     logger.info("Accounting Module API starting...")2     logger.info("Accounting contract: %s", settings.accounting_contract_address)3 4    # Must run before anything else: every service below builds its chain clients5    # lazily from the narrowed settings mapping, and a deployment that can serve no6    # chain should not sync keys or register tokens on its way to failing.7    served_chains = await initialize_verified_chain_rpc_urls(settings)8    logger.info("Serving source chains: %s", sorted(served_chains))910     # Initialize JWT key manager (derives keys from ROFL seed in TEE)11     jwt_key_manager = get_jwt_key_manager()12     await jwt_key_manager.initialize()

Changed files: Services

+178 newsrc/services/rpc_identity.py
  • New shared helper: verifies each configured RPC endpoint's eth_chainId, caches the verified set, and exposes verified_web3(chain_id, …) that returns None for unverified chains.
  • Fail-closed semantics: mismatch or unreachable ⇒ chain excluded with an error log naming both IDs; zero verified chains is the only loud failure.
Show diff · +178 −0
new file mode 100644--- /dev/null+++ b/src/services/rpc_identity.py@@ -0,0 +1,178 @@1"""Startup gate: every RPC endpoint must report the chain ID it is filed under.23Nothing downstream re-checks it. A chain is resolved by dict key and the endpoint4behind that key is trusted, so one filed under the wrong chain — a mainnet URL5under a testnet ID, two chains' URLs swapped — has deposits verified on one chain6while transactions are signed for another, with real funds on both sides.78`verify_chain_rpc_urls` calls `eth_chainId` once per configured endpoint at9startup and keeps only those answering with the ID they are filed under. A10mismatching endpoint is dropped, leaving its chain *unserved* rather than11mis-served: an unserved chain rejects deposits and withdrawals, a mis-served one12moves funds on the wrong chain. An unreachable endpoint is dropped identically,13because it cannot be told apart from a mismatching one without trusting it; a14restart readmits it once it answers.1516Startup aborts only when nothing verifies at all, since that deployment would17otherwise accept deposits it can never verify. A partially verified deployment18keeps serving the chains that passed.19"""2021from __future__ import annotations2223import asyncio24import logging25from typing import Dict, Mapping, Optional2627from web3 import AsyncWeb328from web3.providers import AsyncHTTPProvider2930logger = logging.getLogger(__name__)3132# One hung endpoint must not hold startup open; a timed-out probe excludes its33# chain like any other failed probe.34CHAIN_ID_PROBE_TIMEOUT_SECONDS = 10353637class NoVerifiedChainsError(RuntimeError):38    """No configured RPC endpoint proved its chain ID; the service can serve nothing."""394041# Shared per URL so consumers get the exact client whose identity was probed, and42# so web3's per-provider connection pool is reused.43_clients: Dict[str, AsyncWeb3] = {}4445# None until `initialize_verified_chain_rpc_urls` runs, distinguishing "nothing46# verified" (empty dict — serve nothing) from "the check never ran" (unit tests,47# one-off scripts), which must not silently serve nothing.48_verified_urls: Optional[Dict[int, str]] = None495051def _client_for_url(url: str) -> AsyncWeb3:52    client = _clients.get(url)53    if client is None:54        client = AsyncWeb3(AsyncHTTPProvider(url))55        _clients[url] = client56    return client575859async def _probe_chain_id(url: str, timeout: float) -> int:60    """Return the chain ID the endpoint at ``url`` claims for itself."""61    client = _client_for_url(url)62    return int(await asyncio.wait_for(client.eth.chain_id, timeout))636465async def verify_chain_rpc_urls(66    chain_rpc_urls: Mapping[int, str],67    *,68    timeout: float = CHAIN_ID_PROBE_TIMEOUT_SECONDS,69) -> Dict[int, str]:70    """Probe every endpoint concurrently; return only those reporting their own ID.7172    Mismatching and unreachable endpoints are both logged and dropped; URLs never73    are, since they carry provider API keys. Leaves module state alone — this74    reports, `initialize_verified_chain_rpc_urls` commits.75    """76    chain_ids = sorted(chain_rpc_urls)77    reported_ids = await asyncio.gather(78        *(_probe_chain_id(chain_rpc_urls[chain_id], timeout) for chain_id in chain_ids),79        return_exceptions=True,80    )8182    verified: Dict[int, str] = {}83    for chain_id, reported in zip(chain_ids, reported_ids):84        if isinstance(reported, BaseException):85            logger.error(86                "RPC identity check failed for chain %s (%s: %s) — endpoint excluded, "87                "chain unserved until it answers on restart",88                chain_id,89                type(reported).__name__,90                reported,91            )92            continue93        if reported != chain_id:94            logger.error(95                "RPC identity mismatch: endpoint filed under chain %s reports chain %s — "96                "endpoint excluded, chain %s unserved",97                chain_id,98                reported,99                chain_id,100            )101            continue102        logger.info("RPC identity verified for chain %s", chain_id)103        verified[chain_id] = chain_rpc_urls[chain_id]104    return verified105106107async def initialize_verified_chain_rpc_urls(108    settings,109    *,110    timeout: float = CHAIN_ID_PROBE_TIMEOUT_SECONDS,111) -> Dict[int, str]:112    """Run the identity check once and narrow ``settings.chain_rpc_urls`` to what passed.113114    Narrowing that mapping is what carries the check to consumers that never see115    the verified set: each is built lazily, after this runs, and admits a chain by116    membership in it.117118    Returns the verified mapping. Raises `NoVerifiedChainsError` when endpoints119    were configured and none verified.120    """121    global _verified_urls122123    configured = dict(settings.chain_rpc_urls)124    if not configured:125        # Nothing to mis-serve: an endpoint-less deployment already refuses every126        # chain at the call site.127        logger.warning("No chain RPC URLs configured; skipping RPC identity check")128        _verified_urls = {}129        return {}130131    verified = await verify_chain_rpc_urls(configured, timeout=timeout)132133    excluded = sorted(set(configured) - set(verified))134    if excluded:135        logger.error("Chains excluded by the RPC identity check, now unserved: %s", excluded)136137    # Commit before the abort check so a caller that swallows138    # NoVerifiedChainsError serves nothing rather than the unverified mapping.139    _verified_urls = verified140    # Mutate the mapping every consumer already references rather than rebinding141    # the attribute, so copies taken later see the narrowed set.142    settings.chain_rpc_urls.clear()143    settings.chain_rpc_urls.update(verified)144145    if not verified:146        raise NoVerifiedChainsError(147            f"None of the {len(configured)} configured RPC endpoints reported the chain ID "148            f"they are filed under (chains {sorted(configured)}); refusing to start"149        )150151    logger.info("RPC identity check complete; serving chains %s", sorted(verified))152    return dict(verified)153154155def verified_web3(chain_id: int, chain_rpc_urls: Mapping[int, str]) -> Optional[AsyncWeb3]:156    """Return the shared verified client for ``chain_id``, or None if none is served.157158    Once the startup check has run only verified chains resolve, even when the159    caller still holds an un-narrowed mapping; callers turn the None into their160    own "chain not available" error.161162    ``chain_rpc_urls`` is consulted only when the check never ran — unit tests and163    one-off scripts, where there is no verified set to gate on.164    """165    if _verified_urls is not None:166        url = _verified_urls.get(chain_id)167    else:168        url = chain_rpc_urls.get(chain_id)169    if not url:170        return None171    return _client_for_url(url)172173174def reset_verified_chain_rpc_urls() -> None:175    """Drop the cached verified set and clients. For tests; unused in production."""176    global _verified_urls177    _verified_urls = None178    _clients.clear()
+228 −84src/services/withdrawal_processor.py
  • Readiness gate: before signing anything for a chain, contract nonce must be ≥ the signer's pending nonce on that chain. Divergence refuses processing for that chain with a CRITICAL log, never silently.
  • Exact-hash duplicates: the "nonce too low"/"already known" string matches are gone. On a broadcast error the processor looks up a receipt, then the pending pool, for the hash of its own signed bytes; found ⇒ withdrawal marked paid with that hash; not found ⇒ nothing advances.
  • Per-chain processing extracted into _process_chain so one misconfigured chain skips only itself; catch-up uses the same gate.
Show diff · +228 −84
--- a/src/services/withdrawal_processor.py+++ b/src/services/withdrawal_processor.py@@ -3,16 +3,18 @@1 import asyncio2 import logging3 import time4from typing import Dict, List, Optional, Set5from typing import Any, Dict, List, Optional, Set6 7 from eth_abi import decode8 from hexbytes import HexBytes9 from web3 import AsyncWeb3, Web310from web3.exceptions import TransactionNotFound11 from web3.providers import AsyncHTTPProvider12 13 from src.abi.accounting import ERROR_SELECTORS as _ERROR_SELECTORS_BYTES14 from src.config import CHAIN_NAMES, load_settings15 from src.services.accounting_contract import AccountingContractService16from src.services.rpc_identity import verified_web317 18 logger = logging.getLogger(__name__)19 @@ -63,6 +65,9 @@ class WithdrawalProcessor:1         self._last_rpc_call: float = 02         self._destination_web3: Dict[int, AsyncWeb3] = {}3         self._evm_address: Optional[str] = None4        # Divergence is logged critical the first time and error after, to stay loud5        # without flooding every poll cycle6        self._nonce_divergence_reported: Set[int] = set()7 8     async def _rate_limited_call(self, coro_factory):9         """Execute an async call with rate limiting and retries.@@ -93,12 +98,16 @@ class WithdrawalProcessor:1                 raise2 3     def _get_destination_web3(self, chain_id: int) -> AsyncWeb3:4        """Get or create AsyncWeb3 instance for a destination chain."""5        """Get the destination chain's startup-verified client (see `rpc_identity`).67        Withdrawals are signed for one chain ID and broadcast here, so an endpoint filed8        under the wrong chain would send a signed transaction somewhere it never belonged.9        """10         if chain_id not in self._destination_web3:11            rpc_url = self.settings.chain_rpc_urls.get(chain_id)12            if not rpc_url:13                raise ValueError(f"No RPC URL configured for chain {chain_id}")14            self._destination_web3[chain_id] = AsyncWeb3(AsyncHTTPProvider(rpc_url))15            w3 = verified_web3(chain_id, self.settings.chain_rpc_urls)16            if w3 is None:17                raise ValueError(f"No verified RPC endpoint for chain {chain_id}")18            self._destination_web3[chain_id] = w319         return self._destination_web3[chain_id]20 21     async def _get_evm_address(self) -> str:@@ -109,6 +118,93 @@ class WithdrawalProcessor:1             )2         return self._evm_address3 4    async def _chain_nonce_state(self, chain_id: int) -> Optional[tuple[int, int]]:5        """Read ``(contract_next_nonce, chain_pending_nonce)``, or None if unsafe.67        ``nonces[chainId]`` is the nonce the contract embeds in the next withdrawal it8        signs for that chain. It starts at 0 and ``EVMSignerAndVerifier`` only ever9        increments it (``getEVMNonceAndIncrement``) — there is no setter, so divergence10        cannot be repaired from this side.1112        ``contract >= chain`` is safe: equal means caught up, greater means signed13        withdrawals still awaiting broadcast. ``contract < chain`` is not — the chain has14        already spent nonces the contract never issued, so everything signed from now on15        reuses a spent nonce and can never land. None makes callers refuse the chain16        outright rather than sign into that gap.17        """18        contract_next_nonce = await self._rate_limited_call(19            lambda: self._contract.functions.nonces(chain_id).call()20        )2122        # "pending" so queued-but-unmined transactions count as spent nonces23        evm_address = await self._get_evm_address()24        dest_web3 = self._get_destination_web3(chain_id)25        chain_pending_nonce = await self._rate_limited_call(26            lambda: dest_web3.eth.get_transaction_count(evm_address, "pending")27        )2829        if contract_next_nonce >= chain_pending_nonce:30            self._nonce_divergence_reported.discard(chain_id)31            return contract_next_nonce, chain_pending_nonce3233        chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")34        message = (35            f"{chain_name}: REFUSING WITHDRAWALS - contract nonce {contract_next_nonce} is behind "36            f"the pending nonce {chain_pending_nonce} of {evm_address}. The chain has spent "37            f"{chain_pending_nonce - contract_next_nonce} nonce(s) the contract never issued, so "38            f"every transaction signed for chain {chain_id} would reuse a spent nonce and never "39            f"pay out. nonces({chain_id}) can only advance by signing, so this needs operator "40            f"intervention."41        )42        if chain_id in self._nonce_divergence_reported:43            logger.error(message)44        else:45            self._nonce_divergence_reported.add(chain_id)46            logger.critical(message)47        return None4849    @staticmethod50    def _expected_tx_hash(signed_tx: Any) -> str:51        """Compute the hash a node files ``signed_tx`` under: keccak256 of its raw bytes."""52        raw_bytes = AccountingContractService._as_raw_tx_bytes(signed_tx)53        return HexBytes(Web3.keccak(raw_bytes)).to_0x_hex()5455    async def _find_broadcast_tx(self, chain_id: int, signed_tx: Any) -> Optional[str]:56        """Return the hash of ``signed_tx`` if the destination chain already has it.5758        A rejected broadcast only proves the *nonce* is unusable, never that our59        transaction is what used it: "nonce too low" (geth), "already known" and "invalid60        nonce" (Oasis) read identically whether the withdrawal landed or an unrelated61        transaction burned the nonce. Only a receipt or live mempool entry for the exact62        signed bytes proves payment; None means "not proven", and the caller must leave63        the withdrawal unresolved.64        """65        expected_hash = self._expected_tx_hash(signed_tx)66        dest_web3 = self._get_destination_web3(chain_id)6768        async def fetch_receipt():69            try:70                return await dest_web3.eth.get_transaction_receipt(expected_hash)71            except TransactionNotFound:72                return None7374        async def fetch_transaction():75            try:76                return await dest_web3.eth.get_transaction(expected_hash)77            except TransactionNotFound:78                return None7980        for label, fetch in (("receipt", fetch_receipt), ("pending tx", fetch_transaction)):81            try:82                if await self._rate_limited_call(fetch) is not None:83                    return expected_hash84            except Exception as exc:85                logger.warning(86                    f"Could not look up {label} for {expected_hash} on chain {chain_id}: {exc}"87                )8889        return None9091     async def _catch_up_missing_broadcasts(self, chain_ids: Optional[List[int]] = None):92         """Find and broadcast any resolved-but-not-broadcast withdrawals.93 @@ -126,19 +222,12 @@ class WithdrawalProcessor:1             try:2                 chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")3 4                # Get contract's next nonce (what it will use next)5                contract_next_nonce = await self._rate_limited_call(6                    lambda: self._contract.functions.nonces(chain_id).call()7                )89                # Get current nonce on destination chain (what's been broadcast)10                evm_address = await self._get_evm_address()11                dest_web3 = self._get_destination_web3(chain_id)12                chain_current_nonce = await self._rate_limited_call(13                    lambda: dest_web3.eth.get_transaction_count(evm_address)14                )15                nonce_state = await self._chain_nonce_state(chain_id)16                if nonce_state is None:17                    continue18                contract_next_nonce, chain_current_nonce = nonce_state19 20                if contract_next_nonce <= chain_current_nonce:21                if contract_next_nonce == chain_current_nonce:22                     logger.info(f"{chain_name}: no missing broadcasts")23                     continue24 @@ -232,12 +321,17 @@ class WithdrawalProcessor:1                 logger.info(f"Withdrawal #{index}: broadcast successful, tx_hash={tx_hash}")2                 found_nonces.add(nonce)3             except Exception as exc:4                error_str = str(exc).lower()5                if "nonce too low" in error_str or "already known" in error_str:6                    logger.info(f"Withdrawal #{index}: already broadcast")7                broadcast_hash = await self._find_broadcast_tx(chain_id, signed_tx)8                if broadcast_hash is not None:9                    logger.info(f"Withdrawal #{index}: already broadcast, tx_hash={broadcast_hash}")10                     found_nonces.add(nonce)11                 else:12                    logger.error(f"Withdrawal #{index}: broadcast failed - {exc}")13                    logger.error(14                        f"Withdrawal #{index}: broadcast failed and no transaction matching the "15                        f"signed payload exists on {chain_name} - nonce {nonce} may have been "16                        f"spent by a different transaction, leaving this withdrawal unpayable: "17                        f"{exc}"18                    )19 20             if index > 0 and index % 100 == 0:21                 logger.info(@@ -347,10 +441,25 @@ class WithdrawalProcessor:1 2             # Step 4: Broadcast to destination chain3             logger.info(f"Withdrawal #{index}: broadcasting to {chain_name}")4            tx_hash = await self._rate_limited_call(5                lambda: self.accounting_service._send_raw_transaction(chain_id, signed_tx)6            )7            logger.info(f"Withdrawal #{index}: broadcast successful, tx_hash={tx_hash}")8            try:9                tx_hash = await self._rate_limited_call(10                    lambda: self.accounting_service._send_raw_transaction(chain_id, signed_tx)11                )12            except Exception as exc:13                tx_hash = await self._find_broadcast_tx(chain_id, signed_tx)14                if tx_hash is None:15                    logger.error(16                        f"Withdrawal #{index}: broadcast to {chain_name} failed and no "17                        f"transaction matching the signed payload "18                        f"({self._expected_tx_hash(signed_tx)}) exists there - leaving it "19                        f"unresolved rather than marking it paid: {exc}"20                    )21                    return False22                logger.info(23                    f"Withdrawal #{index}: already broadcast to {chain_name}, tx_hash={tx_hash}"24                )25            else:26                logger.info(f"Withdrawal #{index}: broadcast successful, tx_hash={tx_hash}")27 28             self._chain_high_water_mark[chain_id] = max(29                 self._chain_high_water_mark.get(chain_id, -1), index@@ -358,21 +467,48 @@ class WithdrawalProcessor:1             return True2 3         except Exception as exc:4            error_str = str(exc).lower()5             selector, error_name = decode_contract_error(exc)67            if "nonce too low" in error_str or "already known" in error_str:8                logger.info(f"Withdrawal #{index}: already broadcast to {chain_name}")9                self._chain_high_water_mark[chain_id] = max(10                    self._chain_high_water_mark.get(chain_id, -1), index11                )12                return True13            elif selector:14            if selector:15                 logger.error(f"Withdrawal #{index}: contract error - {error_name}")16                return False17             else:18                 logger.error(f"Withdrawal #{index}: failed - {exc}")19                return False20            return False2122    async def _process_chain(self, chain_id: int, withdrawals: List[Dict]):23        """Process one chain's pending withdrawals in index order.2425        The nonce gate runs once per chain, before anything is signed: on divergence the26        whole chain is skipped, so no withdrawal is resolved against a nonce the27        destination chain has already spent.28        """29        chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")3031        try:32            nonce_state = await self._chain_nonce_state(chain_id)33        except Exception as exc:34            logger.error(f"{chain_name}: nonce readiness check failed, skipping chain: {exc}")35            return3637        if nonce_state is None:38            logger.error(39                f"{chain_name}: skipping {len(withdrawals)} pending withdrawal(s) - "40                f"destination nonce check failed"41            )42            return4344        if withdrawals:45            logger.info(f"Processing {len(withdrawals)} withdrawals for {chain_name}")4647        for withdrawal in withdrawals:48            if not self._is_running:49                return5051            if not await self._resolve_and_broadcast(withdrawal):52                # Withdrawal failed - run catch-up to handle any nonce gaps,53                # then retry on next poll cycle54                logger.warning(f"Withdrawal failed, pausing {chain_name} processing")55                await self._catch_up_missing_broadcasts([chain_id])56                return57 58     async def _run_processor(self):59         """Main processing loop."""@@ -398,22 +534,7 @@ class WithdrawalProcessor:1                     if not self._is_running:2                         break3 4                    chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")5                    if withdrawals:6                        logger.info(f"Processing {len(withdrawals)} withdrawals for {chain_name}")78                    # Process in order for this chain9                    for withdrawal in withdrawals:10                        if not self._is_running:11                            break1213                        success = await self._resolve_and_broadcast(withdrawal)14                        if not success:15                            # Withdrawal failed - run catch-up to handle any nonce gaps,16                            # then retry on next poll cycle17                            logger.warning(f"Withdrawal failed, pausing {chain_name} processing")18                            await self._catch_up_missing_broadcasts([chain_id])19                            break20                    await self._process_chain(chain_id, withdrawals)21 22             except Exception:23                 logger.exception("Error during withdrawal processing poll")@@ -448,6 +569,7 @@ class WithdrawalProcessor:1                 pass2 3         self._chain_high_water_mark.clear()4        self._nonce_divergence_reported.clear()5         logger.info("Withdrawal processor stopped")6 7 
+60 −16src/services/accounting_contract.py
  • Admission check: required destination balance = gasPrice(chain) × withdraw gas limit × 1.2 (+ amount for native). Gas limits are read once from the contract's public getters and cached, with no Python mirrors. A missing gas price now rejects instead of admitting an unresolvable withdrawal.
Show diff · +60 −16
--- a/src/services/accounting_contract.py+++ b/src/services/accounting_contract.py@@ -59,6 +59,10 @@ _TOKEN_LIST_CACHE_TTL = 300  # 5 minutes - token list rarely changes1 # Cache size limits2 _TOKEN_CACHE_MAXSIZE = 1000  # Token metadata cache (context + symbols)3 4# Headroom over the exact gas cost the contract signs with, so a gas price update5# between admission and broadcast does not strand the withdrawal.6_WITHDRAWAL_GAS_BUFFER_PERCENT = 2078 # Note: Balance and user locks are not cached because SIWE token must be9 # validated on each request. In the future, if SIWE validation moves to10 # the API layer, caching could be added here for performance.@@ -129,6 +133,7 @@ class AccountingContractService:1         self.rofl_client = RoflAppdClient()2         self.chain_rpc_urls: Dict[int, str] = dict(self.settings.chain_rpc_urls)3         self._chain_web3: Dict[int, AsyncWeb3] = {}4        self._withdrawal_gas_limits: Dict[str, int] = {}5         self.default_token_symbol = "ETH"6         self.chain_names = CHAIN_NAMES7 @@ -416,21 +421,62 @@ class AccountingContractService:1             is_native=is_native,2         )3 4    async def _get_withdrawal_gas_limit(self, is_native: bool) -> int:5        """Read the gas limit the contract signs withdrawals with (cached).67        ``gasLimitNativeWithdraw``/``gasLimitERC20Withdraw`` are ``public constant`` on8        ``EVMSignerAndVerifier``; reading the getters keeps admission in step with signing9        instead of mirroring numbers that can silently drift apart.10        """11        fn_name = "gasLimitNativeWithdraw" if is_native else "gasLimitERC20Withdraw"12        cached = self._withdrawal_gas_limits.get(fn_name)13        if cached is not None:14            return cached1516        contract_reader = self._get_reader_contract()17        gas_limit = int(await getattr(contract_reader.functions, fn_name)().call())18        self._withdrawal_gas_limits[fn_name] = gas_limit19        return gas_limit2021     async def _check_destination_balance(self, chain_id: int, is_native: bool, amount: int) -> None:22        """Check that evmAddress has enough native balance on the destination chain for gas."""23        chain_w3 = await self._get_chain_web3(chain_id)24        evm_address = await self._get_deposit_address()25        balance = await chain_w3.eth.get_balance(evm_address)26        """Check that evmAddress can pay for this withdrawal on the destination chain.27 28        required = self.settings.min_withdrawal_gas_balance29        Derived from the two values the contract signs with — ``gasPrices[chainId]`` and30        the withdrawal gas limit — because the EVM debits ``gasLimit * gasPrice`` upfront.31        A single global floor cannot express that: at 1e13 wei it admitted ~1000x too32        early on a 100 gwei chain, where an ERC-20 withdrawal needs33        100_000 * 100 gwei = 1e16 wei.34        """35        chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")3637        gas_price = await self.get_gas_price(chain_id)38        if gas_price <= 0:39            raise ValueError(40                f"No gas price published for {chain_name}. The contract cannot sign a "41                f"withdrawal for chain {chain_id} until setGasPrice({chain_id}, ...) lands."42            )4344        gas_limit = await self._get_withdrawal_gas_limit(is_native)45        gas_cost = gas_price * gas_limit46        required = gas_cost * (100 + _WITHDRAWAL_GAS_BUFFER_PERCENT) // 10047        # MIN_WITHDRAWAL_GAS_BALANCE is kept as an additional floor for chains whose48        # published gas price understates what the broadcaster actually needs.49        required = max(required, self.settings.min_withdrawal_gas_balance)50         if is_native:51             required += amount52 53        chain_w3 = await self._get_chain_web3(chain_id)54        evm_address = await self._get_deposit_address()55        balance = await chain_w3.eth.get_balance(evm_address)5657         if balance < required:58            chain_name = CHAIN_NAMES.get(chain_id, f"chain {chain_id}")59            detail = f"{gas_limit} gas x {gas_price} wei +{_WITHDRAWAL_GAS_BUFFER_PERCENT}% buffer"60            if is_native:61                detail += f" + {amount} wei withdrawn"62             raise ValueError(63                 f"Insufficient native balance on {chain_name}. "64                f"EVM address {evm_address} has {balance} wei, needs at least {required} wei."65                f"EVM address {evm_address} has {balance} wei, needs at least {required} wei "66                f"({detail})."67             )68 69     async def _get_token_symbol(self, token: HexBytes) -> Optional[str]:
+20 −4src/services/deposit_discovery.py
  • Web3 client construction goes through rpc_identity.verified_web3; an unverified chain raises DiscoveryNotConfiguredError instead of scanning the wrong network.
Show diff · +20 −4
--- a/src/services/deposit_discovery.py+++ b/src/services/deposit_discovery.py@@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional1 from aiohttp import ClientError2 from web3 import AsyncWeb3, Web33 from web3.exceptions import Web3Exception4from web3.providers import AsyncHTTPProvider5 6 from src.config.chain_config import (7     CHAIN_CONFIGS,@@ -24,6 +23,7 @@ from src.config.chain_config import (1 )2 from src.services.cache import AsyncTTLCache3 from src.services.deposit_processor import compute_deposit_id4from src.services.rpc_identity import verified_web35 6 logger = logging.getLogger(__name__)7 @@ -47,10 +47,11 @@ class DiscoveryRPCError(Exception):1 2 3 class DiscoveryNotConfiguredError(Exception):4    """No source-chain RPC URL is configured for the requested chain.5    """No verified source-chain RPC endpoint is available for the requested chain.6 7     Deployment fault, not caller error: the chain passed route validation8    (it is in CHAIN_CONFIGS) but settings carry no RPC URL for it.9    (it is in CHAIN_CONFIGS) but settings carry no RPC URL for it, or its10    endpoint was excluded by the startup chain-ID identity check.11     """12 13 @@ -95,11 +96,16 @@ class DepositDiscoveryService:1         )2 3     def _get_web3(self, chain_id: int) -> AsyncWeb3:4        """Get the chain's startup-verified client (see `rpc_identity`).56        Scanning an endpoint that may be a different chain is what that check exists to7        prevent, so an excluded chain raises instead of being scanned.8        """9         if chain_id not in self._web3_cache:10            rpc_url = self._chain_rpc_urls.get(chain_id)11            if not rpc_url:12                raise DiscoveryNotConfiguredError(f"No RPC URL configured for chain {chain_id}")13            self._web3_cache[chain_id] = AsyncWeb3(AsyncHTTPProvider(rpc_url))14            w3 = verified_web3(chain_id, self._chain_rpc_urls)15            if w3 is None:16                raise DiscoveryNotConfiguredError(f"No verified RPC endpoint for chain {chain_id}")17            self._web3_cache[chain_id] = w318         return self._web3_cache[chain_id]19 20     async def discover_pending_deposits(
+16 −6src/services/deposit_verifier.py
  • Same verified-client routing for deposit verification.
Show diff · +16 −6
--- a/src/services/deposit_verifier.py+++ b/src/services/deposit_verifier.py@@ -9,12 +9,12 @@ from dataclasses import dataclass1 from typing import Dict, Optional2 3 from web3 import AsyncWeb34from web3.providers import AsyncHTTPProvider5 6 from src.config.chain_config import (7     TRANSFER_EVENT_TOPIC,8     get_finality_depth,9 )10from src.services.rpc_identity import verified_web311 12 logger = logging.getLogger(__name__)13 @@ -53,12 +53,16 @@ class DepositVerifier:1         self._web3_cache: Dict[int, AsyncWeb3] = {}2 3     def _get_web3(self, chain_id: int) -> AsyncWeb3:4        """Get or create an AsyncWeb3 instance for a chain."""5        """Get the chain's startup-verified client (see `rpc_identity`).67        A chain missing here is unconfigured or was excluded by that check; both fail8        closed rather than verify a deposit against a possibly different chain.9        """10         if chain_id not in self._web3_cache:11            rpc_url = self._chain_rpc_urls.get(chain_id)12            if not rpc_url:13                raise ValueError(f"No RPC URL configured for chain {chain_id}")14            self._web3_cache[chain_id] = AsyncWeb3(AsyncHTTPProvider(rpc_url))15            w3 = verified_web3(chain_id, self._chain_rpc_urls)16            if w3 is None:17                raise ValueError(f"No verified RPC endpoint for chain {chain_id}")18            self._web3_cache[chain_id] = w319         return self._web3_cache[chain_id]20 21     async def verify_deposit(
+15 −7src/services/sweep_engine.py
  • Same verified-client routing for sweep/funding broadcasts.
Show diff · +15 −7
--- a/src/services/sweep_engine.py+++ b/src/services/sweep_engine.py@@ -30,11 +30,11 @@ from typing import Any, Dict, Optional, Protocol, Set, runtime_checkable1 2 from web3 import AsyncWeb33 from web3.exceptions import TransactionNotFound4from web3.providers import AsyncHTTPProvider5 6 from src.clients.rofl import TransactionRevertedError7 from src.config.chain_config import GAS_FUNDING_AMOUNT_WEI8 from src.services.l2_fee_estimator import estimate_l1_data_fee9from src.services.rpc_identity import verified_web310 11 logger = logging.getLogger(__name__)12 @@ -220,11 +220,16 @@ class SweepEngine:1         return self._gas_funding_tx_hashes2 3     def _get_web3(self, chain_id: int) -> AsyncWeb3:4        """Get the chain's startup-verified client (see `rpc_identity`).56        Sweeps broadcast signed transactions, so serving an excluded chain would move7        funds on a chain the signature was never meant for.8        """9         if chain_id not in self._web3_cache:10            rpc_url = self._chain_rpc_urls.get(chain_id)11            if not rpc_url:12                raise ValueError(f"No RPC URL configured for chain {chain_id}")13            self._web3_cache[chain_id] = AsyncWeb3(AsyncHTTPProvider(rpc_url))14            w3 = verified_web3(chain_id, self._chain_rpc_urls)15            if w3 is None:16                raise ValueError(f"No verified RPC endpoint for chain {chain_id}")17            self._web3_cache[chain_id] = w318         return self._web3_cache[chain_id]19 20     async def _get_safe_gas_price(self, w3: AsyncWeb3, chain_id: int) -> int:

Changed files: Contracts

+7 −4solidity/contracts/EVMSignerAndVerifier.sol
  • gasLimitNativeSweep: 21,000 → 25,000 (measured need 22,140 on Sapphire; unused gas refunded). Feeds the native-sweep and gas-funding signing sites.
  • Stale comment claiming the caller supplies balance − 21000×gasPrice rewritten to describe reality (full amount sent, gas funded separately), so no hardcoded number sits in prose next to a changed constant.
  • No storage changes.
Show diff · +7 −4
--- a/solidity/contracts/EVMSignerAndVerifier.sol+++ b/solidity/contracts/EVMSignerAndVerifier.sol@@ -43,7 +43,9 @@ abstract contract EVMSignerAndVerifier is Initializable {1     address public roflSignerAddress;2 3     // Sweep gas limits: deposit address → evmAddress (always an EOA)4    uint64 public constant gasLimitNativeSweep = 21000;5    // 25000, not the 21000 EVM floor: Sapphire's confidential VM measured 22,140 gas on a bare6    // value transfer, so 21000 makes every Sapphire native sweep and gas-funding tx run out of gas.7    uint64 public constant gasLimitNativeSweep = 25000;8     uint64 public constant gasLimitERC20Sweep = 65000;9     // Withdrawal gas limits: evmAddress → user-chosen address (may be a contract)10     uint64 public constant gasLimitNativeWithdraw = 50000;@@ -265,7 +267,8 @@ abstract contract EVMSignerAndVerifier is Initializable {1      * @notice Sign a native token sweep: depositAddress → evmAddress.2      * @dev Derives deposit keypair internally, signs via EIP155Signer.sign().3      *      ROFL broadcasts the returned signedTx on the source chain.4     *      ROFL supplies amount (typically balance - 21000*gasPrice) from source chain query.5     *      amount is the full verified deposit; its gas comes from a prior funding tx, so nothing6     *      is withheld here.7      */8     function generateSweepNativeTransfer(9         address beneficiary,
+2 −1solidity/contracts/Accounting.sol
  • VERSION: 1 → 2. Required for hardhat upgrade to act (it skips when on-chain ≥ available).
Show diff · +2 −1
--- a/solidity/contracts/Accounting.sol+++ b/solidity/contracts/Accounting.sol@@ -27,7 +27,7 @@ import {UPUPSUpgradeable} from "./lib/UPUPSUpgradeable.sol";1  */2 contract Accounting is EIP712SignatureVerifier, EVMSignerAndVerifier, OwnableUpgradeable, UPUPSUpgradeable {3     /// @notice Contract version, bumped on each upgrade for tracking/verification.4    uint64 public constant VERSION = 1;5    uint64 public constant VERSION = 2;6 7     /// @dev Maximum entries returned by `getHistory` in a single call.8     uint256 private constant MAX_HISTORY_PAGE_SIZE = 100;
+27 newsolidity/contracts/test/LocalnetERC20.sol
  • Minimal 18-decimal ERC-20 for the localnet same-chain dev path ("LHONOR"). Test contract, not deployed anywhere real.
Show diff · +27 −0
new file mode 100644--- /dev/null+++ b/solidity/contracts/test/LocalnetERC20.sol@@ -0,0 +1,27 @@1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;34import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";56/**7 * @title LocalnetERC208 * @notice 18-decimal ERC20 for the sapphire-localnet dev harness (chain 23293).9 * @dev Stands in for HONOR on Sapphire testnet so the local dev loop exercises the same-chain10 *      deposit path — accounting chain *is* the source chain — that `CHAIN_CONFIGS[23293]`11 *      (src/config/chain_config.py) and `ACCOUNTING_TOKEN_INFO` in `.env.localnet` expect.12 *      Localnet only; deployed at a fixed address by the `deploy-localnet-token` task13 *      (tasks/localnetToken.ts).14 */15contract LocalnetERC20 is ERC20 {16    constructor(address initialHolder, uint256 initialSupply) ERC20("Localnet Honor", "LHONOR") {17        _mint(initialHolder, initialSupply);18    }1920    /**21     * @notice Unpermissioned faucet mint: funds deposit addresses while exercising the22     *         deposit → sweep → credit → withdraw flow. Localnet only.23     */24    function mint(address to, uint256 amount) external {25        _mint(to, amount);26    }27}

Changed files: Solidity tooling

+12 −6solidity/scripts/validate-upgrade.ts
  • The CI "validate upgrade" step only ran validateImplementation (candidate in isolation) while printing "Storage layout validation passed". Messaging now states exactly what is checked (implementation-level rules) and that deployed-layout comparison happens at deploy time via validateUpgrade in deploy.ts. No false safety net.
Show diff · +12 −6
--- a/solidity/scripts/validate-upgrade.ts+++ b/solidity/scripts/validate-upgrade.ts@@ -1,7 +1,7 @@1 import { ethers, upgrades } from 'hardhat';2 3 async function main() {4  console.log('Validating upgrade safety for MockAccounting...');5  console.log('Validating implementation-level upgrade safety for MockAccounting...');6 7   const MockAccounting = await ethers.getContractFactory('MockAccounting');8   await upgrades.validateImplementation(MockAccounting, {@@ -10,7 +10,7 @@ async function main() {1     constructorArgs: [ethers.getAddress('0x0000000000000000000000000000000000000001')],2   } as any);3 4  console.log('Validating upgrade safety for Accounting...');5  console.log('Validating implementation-level upgrade safety for Accounting...');6 7   const Accounting = await ethers.getContractFactory('Accounting');8   await upgrades.validateImplementation(Accounting, {@@ -19,10 +19,14 @@ async function main() {1     constructorArgs: [ethers.getAddress('0x0000000000000000000000000000000000000001')],2   } as any);3 4  console.log('Storage layout validation passed for all contracts');5  console.log(6    'Implementation-level upgrade safety validation passed for all contracts ' +7    '(verified: no disallowed constructors, state variable initial values, selfdestruct, or delegatecall). ' +8    'Note: Storage layout comparison against deployed proxies occurs at deploy time via upgrades.validateUpgrade in tasks/deploy.ts.'9  );10 }11 12 main().catch((error) => {13  console.error('Storage layout validation failed:', error.message);14  console.error('Implementation-level upgrade safety validation failed:', error.message);15   process.exit(1);16 });
+8solidity/tasks/deploy.ts
  • After a localnet deploy, also deploys the deterministic localnet token (see localnetToken.ts). No effect on testnet/mainnet paths.
Show diff · +8 −0
--- a/solidity/tasks/deploy.ts+++ b/solidity/tasks/deploy.ts@@ -10,6 +10,7 @@ import {HardhatEthersSigner} from "@nomicfoundation/hardhat-ethers/signers";1 import {HardhatRuntimeEnvironment} from "hardhat/types";2 import {HttpNetworkConfig} from "hardhat/types/config";3 import { parseRoflAppId } from "./utils/rofl";4import { SAPPHIRE_LOCALNET_CHAIN_ID } from "./localnetToken";5 6 // Return unwrapped Sapphire client bound to SECRET_KEY with plain text7 // transactions. Used for all contract management that should be public.@@ -111,6 +112,13 @@ task("deploy")1     console.log(`EVM signing address: ${await proxy.evmAddress()}`);2     console.log(`Owner: ${await proxy.owner()}`);3 4    // Localnet doubles as its own source chain, so the same-chain deposit path needs an5    // ERC20 there (deployed at a fixed address — see tasks/localnetToken.ts).6    const { chainId } = await hre.ethers.provider.getNetwork();7    if (chainId === SAPPHIRE_LOCALNET_CHAIN_ID) {8      await hre.run("deploy-localnet-token");9    }1011     try {12       await hre.run("verify:sourcify", { address: implAddress, contract: "Accounting" });13     } catch (err) {
+1solidity/tasks/index.ts
  • Registers the new task module.
Show diff · +1 −0
--- a/solidity/tasks/index.ts+++ b/solidity/tasks/index.ts@@ -1,4 +1,5 @@1 import "./deploy";2import "./localnetToken";3 import "./show";4 import "./tokens";5 import "./sign";
+130 newsolidity/tasks/localnetToken.ts
  • Deploys LocalnetERC20 from the fixed, publicly-known hardhat test key at nonce 0, so the token address is the same across every localnet reset (CREATE = f(deployer, nonce)). The matching .env.localnet entry therefore never rots.
Show diff · +130 −0
new file mode 100644--- /dev/null+++ b/solidity/tasks/localnetToken.ts@@ -0,0 +1,130 @@1import '@nomicfoundation/hardhat-ethers';2import '@oasisprotocol/sapphire-hardhat';3import { JsonRpcProvider, Signer } from "ethers";4import { task } from "hardhat/config";5import { HardhatRuntimeEnvironment } from "hardhat/types";6import { HttpNetworkConfig } from "hardhat/types/config";78// sapphire-localnet — see CHAIN_CONFIGS[23293] in src/config/chain_config.py.9export const SAPPHIRE_LOCALNET_CHAIN_ID = 23293n;1011// Well-known public burner key (Hardhat/Anvil test account #0): safe to commit, must12// never hold value.13//14// A CREATE address is keccak256(rlp([deployer, nonce]))[12:] — a pure function of the15// deployer and its nonce, independent of bytecode and constructor args. Deploying from16// this key at nonce 0 therefore pins LOCALNET_TOKEN_ADDRESS across every localnet reset,17// which is what lets .env.localnet hardcode the token in ACCOUNTING_TOKEN_INFO.18export const LOCALNET_TOKEN_DEPLOYER_KEY =19  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";20export const LOCALNET_TOKEN_DEPLOYER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";21export const LOCALNET_TOKEN_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3";2223// 1M LHONOR, 18 decimals. Minted to the network's first configured account —24// the one the deposit/withdraw Hardhat tasks sign with.25const INITIAL_SUPPLY = 1_000_000n * 10n ** 18n;26// Sapphire debits gasLimit * gasPrice upfront and pads gas estimates hard, so a27// single deployment can reserve ~0.2 ROSE. Localnet accounts are seeded with28// thousands of TEST ROSE; 10 covers the deploy plus faucet mints.29const DEPLOYER_FUNDING = 10n * 10n ** 18n;3031/**32 * First account configured for the network: SECRET_KEY when set, otherwise index33 * `initialIndex` of the localnet test mnemonic in hardhat.config.ts. Funds the fixed34 * token deployer and receives the initial supply.35 */36function getFunder(hre: HardhatRuntimeEnvironment, provider: JsonRpcProvider): Signer {37  const secretKey = process.env.SECRET_KEY;38  if (secretKey) {39    return new hre.ethers.Wallet(secretKey, provider);40  }4142  const accounts = (hre.network.config as HttpNetworkConfig).accounts;43  if (Array.isArray(accounts)) {44    return new hre.ethers.Wallet(accounts[0] as string, provider);45  }4647  const hd = accounts as { mnemonic: string; path: string; initialIndex: number; passphrase: string };48  if (!hd?.mnemonic) {49    throw new Error(50      "No account configured for this network: set SECRET_KEY or configure accounts in hardhat.config.ts"51    );52  }53  return hre.ethers.HDNodeWallet.fromPhrase(54    hd.mnemonic,55    hd.passphrase,56    `${hd.path}/${hd.initialIndex}`57  ).connect(provider);58}5960task("deploy-localnet-token")61  .setDescription(62    "Deploy the localnet ERC20 (LHONOR) from a fixed key at nonce 0, so its address is stable across localnet resets"63  )64  .setAction(async (_args, hre) => {65    await hre.run("compile");6667    const network = await hre.ethers.provider.getNetwork();68    if (network.chainId !== SAPPHIRE_LOCALNET_CHAIN_ID) {69      throw new Error(70        `deploy-localnet-token is localnet-only: expected chain ${SAPPHIRE_LOCALNET_CHAIN_ID}, ` +71        `connected to ${network.chainId}`72      );73    }7475    // Unwrapped provider: the token deployment, its transfers, and the Transfer76    // logs the deposit verifier reads must all be plain-text on Sapphire.77    const provider = new JsonRpcProvider((hre.network.config as HttpNetworkConfig).url);7879    const existingCode = await provider.getCode(LOCALNET_TOKEN_ADDRESS);80    if (existingCode !== "0x") {81      console.log(`Localnet ERC20 already deployed at ${LOCALNET_TOKEN_ADDRESS}`);82      return LOCALNET_TOKEN_ADDRESS;83    }8485    const deployerNonce = await provider.getTransactionCount(LOCALNET_TOKEN_DEPLOYER_ADDRESS);86    if (deployerNonce !== 0) {87      throw new Error(88        `Fixed token deployer ${LOCALNET_TOKEN_DEPLOYER_ADDRESS} is at nonce ${deployerNonce}, ` +89        `not 0, and nothing is deployed at ${LOCALNET_TOKEN_ADDRESS}. The CREATE address would ` +90        `not match the hardcoded ACCOUNTING_TOKEN_INFO entry in .env.localnet — restart ` +91        `sapphire-localnet to reset chain state instead of deploying to a drifted address.`92      );93    }9495    const funder = getFunder(hre, provider);96    const funderAddress = await funder.getAddress();9798    const deployerBalance = await provider.getBalance(LOCALNET_TOKEN_DEPLOYER_ADDRESS);99    if (deployerBalance < DEPLOYER_FUNDING) {100      console.log(101        `Funding token deployer ${LOCALNET_TOKEN_DEPLOYER_ADDRESS} with ` +102        `${hre.ethers.formatEther(DEPLOYER_FUNDING - deployerBalance)} TEST ROSE from ${funderAddress}`103      );104      const fundingTx = await funder.sendTransaction({105        to: LOCALNET_TOKEN_DEPLOYER_ADDRESS,106        value: DEPLOYER_FUNDING - deployerBalance,107      });108      await fundingTx.wait();109    }110111    const tokenDeployer = new hre.ethers.Wallet(LOCALNET_TOKEN_DEPLOYER_KEY, provider);112    const LocalnetERC20 = await hre.ethers.getContractFactory("LocalnetERC20", tokenDeployer);113    const token = await LocalnetERC20.deploy(funderAddress, INITIAL_SUPPLY);114    await token.waitForDeployment();115116    const tokenAddress = await token.getAddress();117    if (tokenAddress.toLowerCase() !== LOCALNET_TOKEN_ADDRESS.toLowerCase()) {118      throw new Error(119        `Localnet ERC20 deployed to ${tokenAddress}, expected the deterministic ` +120        `${LOCALNET_TOKEN_ADDRESS}. Update LOCALNET_TOKEN_ADDRESS here and the matching ` +121        `ACCOUNTING_TOKEN_INFO entry in .env.localnet together.`122      );123    }124125    console.log(`Localnet ERC20 (LHONOR) address: ${tokenAddress}`);126    console.log(`Initial supply holder: ${funderAddress} (${hre.ethers.formatEther(INITIAL_SUPPLY)} LHONOR)`);127    console.log("Registered for chain 23293 via ACCOUNTING_TOKEN_INFO in .env.localnet");128129    return tokenAddress;130  });

Changed files: Env & deployment config

+5 −3.env.testnet
  • Removed the bare native {"chain_id": 23295} token entry; only HONOR is registered (registration is not removable once on-chain). Added "23295": 100000000000 (100 gwei) to ACCOUNTING_GAS_PRICE, otherwise every 23295 submission reverts with GasPriceNotSet.
Show diff · +3 −2
--- a/.env.testnet+++ b/.env.testnet@@ -11,13 +11,14 @@ SAPPHIRE_CHAIN_ID=232951 SAPPHIRE_RPC_URL=https://testnet.sapphire.oasis.io2 3 ACCOUNTING_GAS_LIMIT=5000004ACCOUNTING_GAS_PRICE='{"84532": 1000000000, "11155111": 20000000000}'5ACCOUNTING_GAS_PRICE='{"84532": 1000000000, "11155111": 20000000000, "23295": 100000000000}'6 ACCOUNTING_TOKEN_INFO='[7   {"chain_id": 84532},8   {"chain_id": 84532, "token_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"},9   {"chain_id": 84532, "token_address": "0xD733D48f2a7F57D4559F98ae07f87Dab595E3523"},10   {"chain_id": 11155111},11  {"chain_id": 11155111, "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}12  {"chain_id": 11155111, "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"},13  {"chain_id": 23295, "token_address": "0xF5f49fbBBD46C204b836d243995df72A61bC7ce7"}14 ]'15 16 WITHDRAWAL_POLL_INTERVAL=12
+13 −5.env.localnet
  • Mirrors the same-chain setup on 23293: LHONOR token entry at the deterministic address, plus its gas price. Inert in production.
Show diff · +7 −3
--- a/.env.localnet+++ b/.env.localnet@@ -28,17 +28,21 @@ ACCOUNTING_GAS_LIMIT=5000001 # mapping chain_id to gas price. Published on-chain via setGasPrice at every ROFL2 # restart (see services/gas_price_bootstrap.py). Omit a chain to leave its3 # on-chain gas price untouched.4ACCOUNTING_GAS_PRICE='{"84532": 1000000000, "11155111": 20000000000}'5ACCOUNTING_GAS_PRICE='{"84532": 1000000000, "11155111": 20000000000, "23293": 100000000000}'6 7 # Tokens to register on-chain via setTokenInfo at every ROFL restart (see8 # services/token_info_bootstrap.py), as a JSON array. Native tokens for Base9 # Sepolia (84532) and Ethereum Sepolia (11155111), plus Circle's official10# testnet USDC on each chain.11# testnet USDC on each chain, plus the localnet ERC20 (LHONOR) on 23293,12# the same-chain mirror of HONOR on 23295. LHONOR's address is deterministic13# (fixed key at nonce 0, see tasks/localnetToken.ts); if that task ever14# reports a different address, update this entry.15 ACCOUNTING_TOKEN_INFO='[16   {"chain_id": 84532},17   {"chain_id": 84532, "token_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"},18   {"chain_id": 11155111},19  {"chain_id": 11155111, "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}20  {"chain_id": 11155111, "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"},21  {"chain_id": 23293, "token_address": "0x5FbDB2315678afecb367f032d93F642f64180aa3"}22 ]'23 24 # Background service configuration
+92 new.env.rubetest
  • Env baked into the test image used for the live rehearsal; points at the rehearsal contract instead of the live proxy. No secrets.
Show diff · +93 −0
new file mode 100644--- /dev/null+++ b/.env.rubetest@@ -0,0 +1,93 @@1API_HOST=0.0.0.02API_PORT=80003# INFO, not DEBUG: oasis-rofl-client DEBUG logs include full tx payloads pre-encryption.4LOG_LEVEL=INFO5ENVIRONMENT=development67CORS_ALLOWED_ORIGINS=https://testnet.honoroll.io,https://staging.testnet.honoroll.io,http://localhost:3000,https://privana-ui.pages.dev,https://app.testnet.privana.finance89ACCOUNTING_CONTRACT_ADDRESS=0x34D9E2263285129Ae04Ca1214216ebd861e64B6b1011SAPPHIRE_CHAIN_ID=2329512SAPPHIRE_RPC_URL=https://testnet.sapphire.oasis.io1314ACCOUNTING_GAS_LIMIT=50000015ACCOUNTING_GAS_PRICE='{"84532": 1000000000, "11155111": 20000000000, "23295": 100000000000}'16ACCOUNTING_TOKEN_INFO='[17  {"chain_id": 84532},18  {"chain_id": 84532, "token_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"},19  {"chain_id": 84532, "token_address": "0xD733D48f2a7F57D4559F98ae07f87Dab595E3523"},20  {"chain_id": 11155111},21  {"chain_id": 11155111, "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"},22  {"chain_id": 23295, "token_address": "0xF5f49fbBBD46C204b836d243995df72A61bC7ce7"}23]'2425WITHDRAWAL_POLL_INTERVAL=1226WITHDRAWAL_RESOLUTION_TIMEOUT=6027MIN_WITHDRAWAL_GAS_BALANCE=100000000000002829JWT_EXPIRY_HOURS=1230JWT_REFRESH_EXPIRY_DAYS=731JWT_ISSUER=privana32JWT_AUDIENCE=privana3334AUTH_TOKEN_STORAGE_DIR=.auth_tokens35SIWE_DOMAINS=https://api.testnet.privana.finance,https://app.testnet.privana.finance,https://testnet.honoroll.io,https://staging.testnet.honoroll.io,http://localhost:300036SIWE_NONCE_EXPIRY_SECONDS=30037AUTH_TOKEN_VALIDITY_SECONDS=8640038AUTH_TOKEN_KEY_ID=auth_token_enc_seed.v2.key39AUTH_CLIENTS='[40  {41    "client_id":"demo-casino-staging",42    "display_name":"Demo Casino (Staging)",43    "audience":"demo-casino-staging",44    "redirect_uris": [45      "https://testnet.honoroll.io/auth/callback",46      "https://staging.testnet.honoroll.io/auth/callback",47      "http://localhost:3000/auth/callback"48    ]49  },50  {51    "client_id":"privana-ui",52    "display_name":"Privana UI",53    "audience":"privana-ui",54    "redirect_uris": [55      "http://localhost:3000/auth/callback",56      "https://privana-ui.pages.dev/auth/callback",57      "https://app.testnet.privana.finance/auth/callback"58    ]59  }60]'6162AUTH_CODE_TTL_SECONDS=12063AUTH_RATE_LIMIT_WINDOW_SECONDS=606465AUTH_NONCE_RATE_LIMIT=3066AUTH_LOGIN_RATE_LIMIT=1067AUTH_AUTHORIZE_RATE_LIMIT=1068AUTH_TOKEN_RATE_LIMIT=206970TRUST_X_FORWARDED_FOR=false7172# New purchases remain on MoonPay until the controlled deployment flip.73# ONRAMP_PROVIDER=moonpay7475ONRAMP_INTENT_SIGNING_KEY_ID=onramp_intent_signing_key.v1.key76# ONRAMP_INTENT_PREVIOUS_SIGNING_KEY_IDS=7778MOONPAY_API_BASE_URL=https://api.moonpay.com79MOONPAY_ALLOWED_HOSTS=app.testnet.privana.finance,buy-sandbox.moonpay.com80MOONPAY_ALLOWED_CURRENCY_CODES=usdc81MOONPAY_WEBHOOK_TOLERANCE_SECONDS=3008283# Transak staging is deliberately incomplete until the deployment owner sets84# the verified proxy-owned original-client-IP header. A browser-supplied header85# must be overwritten by the proxy before ONRAMP_PROVIDER is changed.86TRANSAK_API_BASE_URL=https://api-stg.transak.com87TRANSAK_GATEWAY_BASE_URL=https://api-gateway-stg.transak.com88TRANSAK_REFERRER_DOMAIN=app.testnet.privana.finance89# TRANSAK_CLIENT_IP_HEADER=X-Original-User-IP90TRANSAK_CRYPTO_CURRENCY_CODE=USDC91TRANSAK_NETWORK=base92TRANSAK_CHAIN_ID=8453293TRANSAK_TOKEN_ADDRESS=0xD733D48f2a7F57D4559F98ae07f87Dab595E3523
+27 newcompose.rubetest.yaml
  • Compose for the rehearsal machine: digest-pinned image, no custom-domain annotation, build args pointed at .env.rubetest so no accidental build targets the live proxy.
Show diff · +27 −0
new file mode 100644--- /dev/null+++ b/compose.rubetest.yaml@@ -0,0 +1,27 @@1services:2  privana:3    build:4      context: .5      args:6        - ENV_FILE=.env.rubetest7    image: "ghcr.io/rube-de/privana-shadow@sha256:9993ae265bce35e794739383ce493841e2f01d8a3b26e706a166a96633a48536"8    platform: linux/amd649    environment:10      - SWEEP_STATE_DIR=/data/sweep-engine11      - ONRAMP_PROVIDER12      - TRANSAK_CLIENT_IP_HEADER13      - ALCHEMY_API_KEY14      - MOONPAY_API_KEY15      - MOONPAY_SECRET_KEY16      - MOONPAY_WEBHOOK_SECRET_KEY17      - TRANSAK_API_KEY18      - TRANSAK_API_SECRET19    volumes:20      - /run/rofl-appd.sock:/run/rofl-appd.sock21      - sweep-engine-data:/data/sweep-engine22    ports:23      - "8000:8000"24    restart: on-failure2526volumes:27  sweep-engine-data:
+32rofl.yaml
  • Separate deployment entry for test rehearsals (own app ID, trust root, and compose). The live testnet deployment is untouched.
Show diff · +35 −0
--- a/rofl.yaml+++ b/rofl.yaml@@ -16,6 +16,41 @@ artifacts:1   container:2     runtime: https://github.com/oasisprotocol/oasis-sdk/releases/download/rofl-containers%2Fv0.9.0/rofl-containers#e2e074d03ab2fbacaacb01e6d63ce093fde04e4cc620dd8804e8afbb203905253 deployments:4  rubetest:5    app_id: rofl1qz20hu6qztr665w0t4d6y75fys6nq0j3xg24y0ey6    network: testnet7    paratime: sapphire8    admin: easy9    oci_repository: rofl.sh/68e77c16-1772-4704-a61e-46b5b0d40329:178705663710    artifacts:11      container:12        compose: compose.rubetest.yaml13    trust_root:14      height: 3352886215      hash: 85a14586757cf1c2faf0b330061cad488ed173d213fe075f1dd19996c0b706a916    policy:17      quotes:18        pcs:19          tcb_validity_period: 3020          min_tcb_evaluation_data_number: 1821          tdx: {}22      enclaves:23        - id: HrmgPQNWyy9pnt7g4aTJttaWRg+iI/JPOpI9X971GCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==24        - id: zHfMCzeB4Kp3/BNGNEPVuhn88nY0A49MJem2ys//JbMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==25      endorsements:26        - any: {}27      fees: endorsing_node28      max_expiration: 329    secrets:30      - name: ALCHEMY_API_KEY31        value: pGJwa1ggx0W8WwyLtMy9DrUCxrzDHQDmd1byeEPOsoj8v2oHtW5kbmFtZVgf2cX8oK6/8yxXobeL47bCpKB++Bgd+YSjBXgJJp/zImVub25jZU9j2hhC1iug9wI0WjAOTZZldmFsdWVYJrO+lggTX3we+OuUAls0Spn/pR/msjQvPTGPDh/NPrjXuSPZMWe432    machines:33      default:34        provider: oasis1qp2ens0hsp7gh23wajxa4hpetkdek3swyyulyrmz35        offer: playground_internal_m36        permissions:37          log.view:38            - oasis1qpsmn7u78k9gm6jyra6zm2lu0n949pztgqpxss4n39   testnet:40     default: true41     app_id: rofl1qrmnjkx47f4tcfvfclnrtj2rad82akeum5jcpe8y
+16 new.gitleaksignore
  • Allowlists gitleaks false positives on public on-chain token addresses and the canonical hardhat test key across the env files and the localnet token task.
Show diff · +19 −0
new file mode 100644--- /dev/null+++ b/.gitleaksignore@@ -0,0 +1,19 @@1# False positives: public on-chain token/contract addresses and the canonical2# hardhat test-account #0 key (publicly documented, localnet-only burner).3.env.testnet:generic-api-key:204.env.testnet:generic-api-key:215.env.localnet:generic-api-key:506.env.localnet:generic-api-key:517solidity/tasks/localnetToken.ts:generic-api-key:138solidity/tasks/localnetToken.ts:generic-api-key:219solidity/tasks/localnetToken.ts:generic-api-key:2310solidity/tasks/localnetToken.ts:generic-api-key:2511.env.rubetest:generic-api-key:1712.env.rubetest:generic-api-key:1813.env.rubetest:generic-api-key:2014.env.rubetest:generic-api-key:2115.env.rubetest:generic-api-key:9216test/py/test_accounting_routes.py:generic-api-key:72817test/py/test_accounting_routes.py:generic-api-key:74418test/py/test_accounting_routes.py:generic-api-key:74519test/py/test_accounting_routes.py:generic-api-key:787
+1docs/openapi.json
  • Regenerated spec drift from the min_deposit shape change.
Show diff · +1 −0
--- a/docs/openapi.json+++ b/docs/openapi.json@@ -245,6 +245,7 @@1               },2               "type": "object"3             },4            "description": "Minimum deposit amounts per chain for supported asset types ('native', 'erc20')",5             "title": "Min Deposit",6             "type": "object"7           },

Changed files: Tests

+13test/conftest.py
  • Autouse fixture resetting the process-wide verified-RPC set between tests, so one test's identity check can't narrow every later test's served chains.
Show diff · +13 −0
--- a/test/conftest.py+++ b/test/conftest.py@@ -13,6 +13,7 @@ import src.auth.rate_limiter as rate_limiter1 import src.auth.token_store as token_store2 import src.config3 import src.services.onramp_intent as onramp_intent4import src.services.rpc_identity as rpc_identity5 6 load_dotenv(".env.localnet")7 @@ -32,6 +33,18 @@ def _run_async(coro):1         return asyncio.new_event_loop().run_until_complete(coro)2 3 4@pytest.fixture(autouse=True)5def reset_rpc_identity():6    """Drop the process-wide verified-RPC set between tests.78    A test that runs the identity check would otherwise leave every later test9    serving only the chains that test verified.10    """11    rpc_identity.reset_verified_chain_rpc_urls()12    yield13    rpc_identity.reset_verified_chain_rpc_urls()141516 @pytest.fixture17 def reset_auth_singletons(monkeypatch, tmp_path):18     """Reset auth singletons so tests remain isolated."""
+76test/py/test_chain_config.py
  • 23295 present, chunk ≤ 100, 18-decimal ERC-20 floor; Sapphire RPC seeded with and without an Alchemy key; gas funding covers 25,000 × gas price on every chain.
Show diff · +76 −0
--- a/test/py/test_chain_config.py+++ b/test/py/test_chain_config.py@@ -7,10 +7,12 @@ from src.config import (1     NATIVE_TOKEN_DECIMALS,2     NATIVE_TOKEN_NAMES,3     NATIVE_TOKEN_SYMBOLS,4    _build_chain_rpc_urls,5     _build_gas_prices,6     _build_token_infos,7 )8 from src.config.chain_config import (9    CHAIN_CONFIGS,10     MIN_DEPOSIT_ERC20_WEI,11     MIN_DEPOSIT_NATIVE_WEI,12     ChainConfig,@@ -181,3 +183,77 @@ def test_build_token_infos_rejects_invalid_token_address(monkeypatch):1 2     with pytest.raises(ValueError, match="entry 0 token_address is not a valid address"):3         _build_token_infos()456def test_sapphire_testnet_runtime_metadata():7    assert CHAIN_NAMES[23295] == "Sapphire Testnet"8    assert NATIVE_TOKEN_SYMBOLS[23295] == "ROSE"9    assert NATIVE_TOKEN_NAMES[23295] == "Rose"10    assert NATIVE_TOKEN_DECIMALS[23295] == 18111213def test_sapphire_testnet_chain_config_m6_1():14    assert 23295 in CHAIN_CONFIGS15    cfg = CHAIN_CONFIGS[23295]16    assert cfg.discovery_scan_chunk_blocks <= 10017    assert cfg.min_deposit_erc20_wei == 10**1818    assert cfg.finality_depth == 219    assert cfg.discovery_lookback_blocks == 64020    assert cfg.discovery_max_lookback_blocks == 3_800212223def test_sapphire_localnet_chain_config():24    assert 23293 in CHAIN_CONFIGS25    cfg = CHAIN_CONFIGS[23293]26    assert cfg.discovery_scan_chunk_blocks <= 10027    assert cfg.min_deposit_erc20_wei == 10**1828    assert cfg.finality_depth == 2293031def test_gas_funding_covers_native_sweep_limit_m3_4():32    """Every chain funds gas for a native sweep (25,000 gas) at its real gas price."""33    expected_reasonable_gas_prices = {34        23295: 100_000_000_000,  # 100 gwei35        23293: 100_000_000_000,  # 100 gwei36        84532: 3_000_000_000,  # 3 gwei37        11155111: 30_000_000_000,  # 30 gwei38    }39    native_sweep_gas_limit = 25_0004041    for chain_id, cfg in CHAIN_CONFIGS.items():42        reasonable_gas_price = expected_reasonable_gas_prices.get(chain_id)43        assert reasonable_gas_price is not None, f"Missing test gas price baseline for {chain_id}"44        min_required_gas_funding = native_sweep_gas_limit * reasonable_gas_price45        assert cfg.gas_funding_amount_wei >= min_required_gas_funding, (46            f"chain {chain_id}: gas_funding_amount_wei ({cfg.gas_funding_amount_wei}) "47            f"must be >= 25,000 * {reasonable_gas_price} ({min_required_gas_funding})"48        )495051def test_build_chain_rpc_urls_without_alchemy_key():52    urls = _build_chain_rpc_urls(53        alchemy_api_key=None,54        sapphire_chain_id=23295,55        sapphire_rpc_url="https://testnet.sapphire.oasis.io",56    )57    assert urls == {23295: "https://testnet.sapphire.oasis.io"}585960def test_build_chain_rpc_urls_with_placeholder_alchemy_key():61    urls = _build_chain_rpc_urls(62        alchemy_api_key="your-alchemy-api-key-here",63        sapphire_chain_id=23295,64        sapphire_rpc_url="https://testnet.sapphire.oasis.io",65    )66    assert urls == {23295: "https://testnet.sapphire.oasis.io"}676869def test_build_chain_rpc_urls_with_valid_alchemy_key():70    urls = _build_chain_rpc_urls(71        alchemy_api_key="secret-alchemy-key",72        sapphire_chain_id=23295,73        sapphire_rpc_url="https://testnet.sapphire.oasis.io",74    )75    assert urls[23295] == "https://testnet.sapphire.oasis.io"76    assert urls[84532] == "https://base-sepolia.g.alchemy.com/v2/secret-alchemy-key"77    assert urls[11155111] == "https://eth-sepolia.g.alchemy.com/v2/secret-alchemy-key"
+72test/py/test_accounting_routes.py
  • 23295 advertises ERC-20 only; 84532 advertises both; /deposits/pending accepts 23295.
Show diff · +71 −0
--- a/test/py/test_accounting_routes.py+++ b/test/py/test_accounting_routes.py@@ -9,6 +9,7 @@ from web3.exceptions import ContractCustomError1 2 import src.api.routes as routes3 import src.auth.dependencies as auth_dependencies4from src.config.chain_config import MIN_DEPOSIT_ERC20_WEI, MIN_DEPOSIT_NATIVE_WEI5 from src.models.private_read import PrivateReadAuth6 from src.services.accounting_contract import SubmissionResult7 from src.services.deposit_processor import DepositProcessor@@ -708,3 +709,73 @@ def test_pending_deposits_requires_auth(monkeypatch) -> None:1     response = client.get("/v1/accounting/deposits/pending", params={"chain_id": 84532})2     assert response.status_code == 4013     discovery.discover_pending_deposits.assert_not_called()456def test_get_deposit_address_advertises_only_configured_asset_types(monkeypatch) -> None:7    """A chain advertises a minimum only for the asset types registered on it."""8    mock_service = MagicMock()9    mock_service.get_deposit_address = AsyncMock(10        return_value="0x" + "aa" * 20,11    )12    monkeypatch.setattr(routes, "_service", mock_service)1314    fake_settings = MagicMock()15    fake_settings.token_infos = [16        {"chain_id": 84532, "token_address": None},17        {"chain_id": 84532, "token_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"},18        {"chain_id": 23295, "token_address": "0xF5f49fbBBD46C204b836d243995df72A61bC7ce7"},19    ]20    fake_settings.chain_rpc_urls = {21        84532: "https://base-sepolia.g.alchemy.com/v2/test",22        23295: "https://testnet.sapphire.oasis.io",23    }24    monkeypatch.setattr(routes, "load_settings", lambda: fake_settings)2526    client = _make_private_read_client(token=b"\xab" * 32)27    response = client.post(28        "/v1/accounting/deposits/address",29        json={"chain_type": "evm", "version": 0},30    )31    assert response.status_code == 200, response.text32    body = response.json()33    assert body["deposit_address"] == "0x" + "aa" * 2034    assert body["chain_type"] == "evm"35    assert body["version"] == 03637    min_23295 = body["min_deposit"]["23295"]38    assert "native" not in min_2329539    assert min_23295["erc20"] == str(MIN_DEPOSIT_ERC20_WEI[23295])4041    min_84532 = body["min_deposit"]["84532"]42    assert min_84532["native"] == str(MIN_DEPOSIT_NATIVE_WEI[84532])43    assert min_84532["erc20"] == str(MIN_DEPOSIT_ERC20_WEI[84532])4445    min_11155111 = body["min_deposit"].get("11155111", {})46    assert "native" not in min_1115511147    assert "erc20" not in min_11155111484950def test_pending_deposits_accepts_sapphire_chain_23295(monkeypatch) -> None:51    from src.services.deposit_discovery import DiscoveryResult5253    result = DiscoveryResult(54        pending=[],55        scanned_from_block=10_000,56        scanned_to_block=10_100,57    )58    client, mock_service, discovery, rate_limit = _make_discovery_client(monkeypatch, result)5960    response = client.get("/v1/accounting/deposits/pending", params={"chain_id": 23295})6162    assert response.status_code == 200, response.text63    body = response.json()64    assert body["scanned_from_block"] == 10_00065    assert body["scanned_to_block"] == 10_10066    assert body["pending"] == []6768    rate_limit.assert_called_once()69    mock_service.get_deposit_address.assert_awaited_once()70    kwargs = discovery.discover_pending_deposits.call_args.kwargs71    assert kwargs["chain_id"] == 2329572    assert kwargs["deposit_address"] == "0x" + "aa" * 2073    assert kwargs["beneficiary"] == BENEFICIARY
+183test/py/test_main_lifespan.py
  • Lifespan ordering with the identity check wired in; fail-closed behaviour for mis-filed RPCs; the half-configured state (tokens registered but a gas price never landed).
Show diff · +178 −0
--- a/test/py/test_main_lifespan.py+++ b/test/py/test_main_lifespan.py@@ -9,6 +9,87 @@ import pytest1 from uvicorn.protocols.http.httptools_impl import RequestResponseCycle2 3 import src.main as main4import src.services.gas_price_bootstrap as gas_price_bootstrap5import src.services.rpc_identity as rpc_identity67LIFESPAN_CHAIN = 845328LIFESPAN_RPC_URL = "https://base-sepolia.example.invalid/key"9MIS_FILED_CHAIN = 2329510MIS_FILED_URL = "https://mis-filed.example.invalid"111213def _lifespan_settings() -> SimpleNamespace:14    """Settings stand-in — never the load_settings() singleton.1516    The identity check narrows ``chain_rpc_urls`` in place, so a narrowed17    singleton would leak into later tests.18    """19    return SimpleNamespace(20        accounting_contract_address="0x" + "ab" * 20,21        chain_rpc_urls={LIFESPAN_CHAIN: LIFESPAN_RPC_URL},22        token_infos=[{"chain_id": LIFESPAN_CHAIN, "token_address": None}],23        gas_prices_wei={LIFESPAN_CHAIN: 3_000_000_000},24    )252627def _wire_lifespan(28    monkeypatch,29    settings: SimpleNamespace,30    steps: list[str],31    *,32    stub_identity: bool = True,33) -> SimpleNamespace:34    """Patch everything the lifespan touches, appending each step to ``steps``."""35    monkeypatch.delenv("DISABLE_ROFL_KEYS", raising=False)36    monkeypatch.setattr(main, "settings", settings)3738    def step(name: str, result=None) -> AsyncMock:39        async def recorded(*_args, **_kwargs):40            steps.append(name)41            return result4243        return AsyncMock(side_effect=recorded)4445    jwt_key_manager = SimpleNamespace(initialize=step("jwt_keys"))46    auth_token_key_manager = SimpleNamespace(47        initialize=step("auth_token_keys"),48        sync_key_to_contract=step("sync_key_to_contract"),49    )50    onramp_intent_key_manager = SimpleNamespace(initialize=step("onramp_intent_keys"))51    accounting = MagicMock()52    withdrawal_processor = SimpleNamespace(53        start=step("withdrawal_start"), stop=step("withdrawal_stop")54    )55    deposit_processor = SimpleNamespace(56        resume_incomplete_sweeps=step("resume_sweeps"),57        start_recovery_loop=MagicMock(side_effect=lambda: steps.append("recovery_loop")),58        stop=step("processor_stop"),59    )6061    def get_deposit_processor() -> SimpleNamespace:62        steps.append("deposit_processor")63        return deposit_processor6465    if stub_identity:66        monkeypatch.setattr(67            main,68            "initialize_verified_chain_rpc_urls",69            step("rpc_identity", {LIFESPAN_CHAIN: LIFESPAN_RPC_URL}),70        )71    monkeypatch.setattr(main, "get_jwt_key_manager", lambda: jwt_key_manager)72    monkeypatch.setattr(main, "get_auth_token_key_manager", lambda: auth_token_key_manager)73    monkeypatch.setattr(main, "get_onramp_intent_key_manager", lambda: onramp_intent_key_manager)74    monkeypatch.setattr(main, "get_accounting_contract_service", lambda: accounting)75    monkeypatch.setattr(main, "bootstrap_rofl_signer_address", step("rofl_signer"))76    monkeypatch.setattr(main, "bootstrap_token_info", step("token_info"))77    monkeypatch.setattr(main, "bootstrap_gas_prices", step("gas_prices"))78    monkeypatch.setattr(main, "get_withdrawal_processor", lambda: withdrawal_processor)79    monkeypatch.setattr(main, "get_deposit_processor", get_deposit_processor)80    return SimpleNamespace(81        accounting=accounting,82        deposit_processor=deposit_processor,83        withdrawal_processor=withdrawal_processor,84    )85 86 87 def test_sensitive_access_log_middleware_is_outermost() -> None:@@ -164,6 +245,11 @@ async def test_uvicorn_access_log_uses_redacted_scope(caplog) -> None:1 @pytest.mark.asyncio2 async def test_lifespan_aborts_when_auth_token_key_sync_fails(monkeypatch) -> None:3     monkeypatch.delenv("DISABLE_ROFL_KEYS", raising=False)4    # The identity check runs first; unstubbed it would probe the real5    # endpoints before reaching the abort under test.6    monkeypatch.setattr(7        main, "initialize_verified_chain_rpc_urls", AsyncMock(return_value={LIFESPAN_CHAIN: ""})8    )9 10     jwt_key_manager = SimpleNamespace(initialize=AsyncMock())11     auth_token_key_manager = SimpleNamespace(@@ -194,6 +280,9 @@ async def test_lifespan_aborts_when_onramp_intent_key_derivation_fails(1     monkeypatch,2 ) -> None:3     monkeypatch.delenv("DISABLE_ROFL_KEYS", raising=False)4    monkeypatch.setattr(5        main, "initialize_verified_chain_rpc_urls", AsyncMock(return_value={LIFESPAN_CHAIN: ""})6    )7 8     jwt_key_manager = SimpleNamespace(initialize=AsyncMock())9     auth_token_key_manager = SimpleNamespace(@@ -217,3 +306,92 @@ async def test_lifespan_aborts_when_onramp_intent_key_derivation_fails(1             pass2 3     auth_token_key_manager.sync_key_to_contract.assert_not_awaited()456@pytest.mark.asyncio7async def test_lifespan_verifies_rpc_identity_before_touching_any_chain(monkeypatch) -> None:8    steps: list[str] = []9    _wire_lifespan(monkeypatch, _lifespan_settings(), steps)1011    async with main.lifespan(None):12        startup = list(steps)1314    # Identity check is first: nothing may read a chain, write to the contract,15    # or register a token on an unverified endpoint. Token registration precedes gas-price sync.16    assert startup == [17        "rpc_identity",18        "jwt_keys",19        "auth_token_keys",20        "onramp_intent_keys",21        "sync_key_to_contract",22        "rofl_signer",23        "token_info",24        "gas_prices",25        "withdrawal_start",26        "deposit_processor",27        "resume_sweeps",28        "recovery_loop",29    ]30    assert steps[len(startup) :] == ["processor_stop", "withdrawal_stop"]313233@pytest.mark.asyncio34async def test_lifespan_leaves_a_mis_filed_chain_unserved(monkeypatch, caplog) -> None:35    steps: list[str] = []36    settings = _lifespan_settings()37    settings.chain_rpc_urls[MIS_FILED_CHAIN] = MIS_FILED_URL38    _wire_lifespan(monkeypatch, settings, steps, stub_identity=False)3940    async def probe(url: str, _timeout: float) -> int:41        return {LIFESPAN_RPC_URL: LIFESPAN_CHAIN, MIS_FILED_URL: 1}[url]4243    monkeypatch.setattr(rpc_identity, "_probe_chain_id", probe)4445    with caplog.at_level(logging.ERROR, logger="src.services.rpc_identity"):46        async with main.lifespan(None):47            # Excluded, not mis-served: the chain is dropped from the served set48            # before token registration or client construction.49            assert settings.chain_rpc_urls == {LIFESPAN_CHAIN: LIFESPAN_RPC_URL}50            assert (51                rpc_identity.verified_web3(MIS_FILED_CHAIN, {MIS_FILED_CHAIN: MIS_FILED_URL})52                is None53            )54            assert rpc_identity.verified_web3(LIFESPAN_CHAIN, {}) is not None5556    # One bad endpoint does not abort startup; valid chains continue serving.57    assert "withdrawal_start" in steps58    assert any(str(MIS_FILED_CHAIN) in record.getMessage() for record in caplog.records)596061@pytest.mark.asyncio62async def test_lifespan_completes_when_a_registered_chain_has_no_gas_price(63    monkeypatch, caplog64) -> None:65    steps: list[str] = []66    settings = _lifespan_settings()67    handles = _wire_lifespan(monkeypatch, settings, steps)6869    async def failing_gas_price(_chain_id: int) -> int:70        steps.append("gas_price_attempt")71        raise RuntimeError("rofl-appd unreachable")7273    # Gas-price bootstrap is best-effort and returns after retries are exhausted.74    monkeypatch.setattr(gas_price_bootstrap, "_BASE_RETRY_DELAY", 0)75    handles.accounting.get_gas_price = AsyncMock(side_effect=failing_gas_price)76    monkeypatch.setattr(main, "bootstrap_gas_prices", gas_price_bootstrap.bootstrap_gas_prices)7778    with caplog.at_level(logging.ERROR, logger="src.services.gas_price_bootstrap"):79        async with main.lifespan(None):80            pass8182    # Token registration is permanent on the contract, while the chain carries no83    # gas price until synced. Startup completes regardless.84    assert steps.count("gas_price_attempt") == gas_price_bootstrap._MAX_ATTEMPTS85    assert steps.index("token_info") < steps.index("gas_price_attempt")86    assert steps.index("gas_price_attempt") < steps.index("withdrawal_start")87    assert steps[-1] == "withdrawal_stop"88    assert settings.chain_rpc_urls == {LIFESPAN_CHAIN: LIFESPAN_RPC_URL}89    assert any(90        "Failed to sync gas price for chain 84532" in record.getMessage()91        for record in caplog.records92    )
+191 newtest/py/test_rpc_identity.py
  • Match/mismatch/unreachable branches of the identity check; verified-set caching; reset semantics.
Show diff · +191 −0
new file mode 100644--- /dev/null+++ b/test/py/test_rpc_identity.py@@ -0,0 +1,191 @@1"""Tests for the fail-closed startup RPC identity check.23The check is the only thing standing between a mis-filed endpoint and a service4that verifies deposits on one chain while signing transactions for another, so5every test here asserts the *exclusion*, not just the log line. The verified set6is process-wide state; `test/conftest.py` resets it between tests.7"""89from types import SimpleNamespace10from unittest.mock import AsyncMock, MagicMock1112import pytest1314import src.services.rpc_identity as rpc_identity15from src.services.deposit_discovery import DepositDiscoveryService, DiscoveryNotConfiguredError16from src.services.deposit_verifier import DepositVerifier17from src.services.rpc_identity import (18    NoVerifiedChainsError,19    initialize_verified_chain_rpc_urls,20    verified_web3,21    verify_chain_rpc_urls,22)2324GOOD_CHAIN = 8453225GOOD_URL = "https://base-sepolia.example.invalid/key"26SAPPHIRE_CHAIN = 2329527SAPPHIRE_URL = "https://testnet.sapphire.example.invalid"282930def _probe(reported: dict[str, object]):31    """Stub the network seam: map URL -> reported chain ID, or an exception to raise."""3233    async def probe(url: str, timeout: float) -> int:34        answer = reported[url]35        if isinstance(answer, BaseException):36            raise answer37        return answer3839    return probe404142def _settings(chain_rpc_urls: dict[int, str]) -> SimpleNamespace:43    # Never the real load_settings() singleton: initialization narrows the mapping44    # in place, and a narrowed singleton would leak into every later test.45    return SimpleNamespace(chain_rpc_urls=dict(chain_rpc_urls))464748async def test_matching_endpoints_are_verified_and_share_one_client(monkeypatch):49    monkeypatch.setattr(50        rpc_identity,51        "_probe_chain_id",52        _probe({GOOD_URL: GOOD_CHAIN, SAPPHIRE_URL: SAPPHIRE_CHAIN}),53    )54    settings = _settings({GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL})5556    served = await initialize_verified_chain_rpc_urls(settings)5758    assert served == {GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL}59    assert settings.chain_rpc_urls == served60    # One client per endpoint, reused by every consumer: the client handed out is61    # the one whose identity was probed.62    client = verified_web3(SAPPHIRE_CHAIN, {})63    assert client is not None64    assert verified_web3(SAPPHIRE_CHAIN, {}) is client65    assert verified_web3(GOOD_CHAIN, {}) is not client666768async def test_mismatched_endpoint_is_excluded_with_both_ids_logged(monkeypatch, caplog):69    # Sapphire testnet URL filed under the Sapphire chain ID but answering with70    # mainnet's: the URL mix-up this check exists for.71    monkeypatch.setattr(72        rpc_identity,73        "_probe_chain_id",74        _probe({GOOD_URL: GOOD_CHAIN, SAPPHIRE_URL: 23294}),75    )76    settings = _settings({GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL})7778    with caplog.at_level("ERROR", logger="src.services.rpc_identity"):79        served = await initialize_verified_chain_rpc_urls(settings)8081    assert served == {GOOD_CHAIN: GOOD_URL}82    assert SAPPHIRE_CHAIN not in settings.chain_rpc_urls83    # Fail closed even for a caller still holding the un-narrowed mapping.84    assert verified_web3(SAPPHIRE_CHAIN, {SAPPHIRE_CHAIN: SAPPHIRE_URL}) is None8586    mismatch_logs = [r.getMessage() for r in caplog.records if "mismatch" in r.getMessage()]87    assert len(mismatch_logs) == 188    assert str(SAPPHIRE_CHAIN) in mismatch_logs[0]89    assert "23294" in mismatch_logs[0]90    # URLs carry provider API keys and must never reach the log.91    assert SAPPHIRE_URL not in caplog.text929394async def test_unreachable_endpoint_is_excluded_like_a_mismatch(monkeypatch, caplog):95    monkeypatch.setattr(96        rpc_identity,97        "_probe_chain_id",98        _probe({GOOD_URL: GOOD_CHAIN, SAPPHIRE_URL: TimeoutError("no answer")}),99    )100    settings = _settings({GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL})101102    with caplog.at_level("ERROR", logger="src.services.rpc_identity"):103        served = await initialize_verified_chain_rpc_urls(settings)104105    # An endpoint that could not be reached cannot be told apart from one filed106    # under the wrong chain, so it is dropped rather than trusted. Reachable107    # chains keep serving; one dead endpoint does not take down the deployment.108    assert served == {GOOD_CHAIN: GOOD_URL}109    assert verified_web3(SAPPHIRE_CHAIN, settings.chain_rpc_urls) is None110    assert verified_web3(GOOD_CHAIN, settings.chain_rpc_urls) is not None111    assert any("TimeoutError" in r.getMessage() for r in caplog.records)112113114async def test_startup_aborts_when_no_endpoint_verifies(monkeypatch):115    monkeypatch.setattr(116        rpc_identity,117        "_probe_chain_id",118        _probe({GOOD_URL: 1, SAPPHIRE_URL: 1}),119    )120    settings = _settings({GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL})121122    with pytest.raises(NoVerifiedChainsError, match="refusing to start"):123        await initialize_verified_chain_rpc_urls(settings)124125    # Committed before the raise: swallowing the error still serves nothing.126    assert settings.chain_rpc_urls == {}127    assert verified_web3(GOOD_CHAIN, {GOOD_CHAIN: GOOD_URL}) is None128129130async def test_no_configured_endpoints_does_not_abort_startup(monkeypatch):131    async def unreachable_probe(url: str, timeout: float) -> int:132        raise AssertionError("nothing to probe")133134    monkeypatch.setattr(rpc_identity, "_probe_chain_id", unreachable_probe)135136    # A deployment with no endpoints has nothing to mis-serve; it already137    # refuses every chain at the call site, so startup is not the place to fail.138    assert await initialize_verified_chain_rpc_urls(_settings({})) == {}139    assert verified_web3(GOOD_CHAIN, {GOOD_CHAIN: GOOD_URL}) is None140141142async def test_verify_reports_without_committing(monkeypatch):143    monkeypatch.setattr(144        rpc_identity,145        "_probe_chain_id",146        _probe({GOOD_URL: GOOD_CHAIN, SAPPHIRE_URL: 1}),147    )148    configured = {GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL}149150    assert await verify_chain_rpc_urls(configured) == {GOOD_CHAIN: GOOD_URL}151    # Reporting neither narrows the caller's mapping nor arms the gate: with no152    # verified set committed, the excluded chain still resolves from the mapping.153    assert configured == {GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL}154    assert verified_web3(SAPPHIRE_CHAIN, configured) is not None155156157async def test_consumers_refuse_an_excluded_chain(monkeypatch):158    monkeypatch.setattr(159        rpc_identity,160        "_probe_chain_id",161        _probe({GOOD_URL: GOOD_CHAIN, SAPPHIRE_URL: 1}),162    )163    settings = _settings({GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL})164    await initialize_verified_chain_rpc_urls(settings)165166    # Services built with the pre-check mapping still fail closed: the167    # verified set, not the caller's dict key, decides what is served.168    stale = {GOOD_CHAIN: GOOD_URL, SAPPHIRE_CHAIN: SAPPHIRE_URL}169    verifier = DepositVerifier(dict(stale))170    discovery = DepositDiscoveryService(171        accounting_service=MagicMock(list_all_tokens=AsyncMock(return_value=[])),172        sweep_engine=MagicMock(),173        chain_rpc_urls=dict(stale),174    )175176    with pytest.raises(ValueError, match=f"No verified RPC endpoint for chain {SAPPHIRE_CHAIN}"):177        verifier._get_web3(SAPPHIRE_CHAIN)178    with pytest.raises(DiscoveryNotConfiguredError, match="No verified RPC endpoint"):179        discovery._get_web3(SAPPHIRE_CHAIN)180181    assert verifier._get_web3(GOOD_CHAIN) is discovery._get_web3(GOOD_CHAIN)182183184async def test_consumers_fall_back_to_configured_urls_when_check_never_ran():185    # Unit tests and one-off scripts never run the startup check; there is no186    # verified set to gate on, so the injected mapping is used as-is.187    verifier = DepositVerifier({GOOD_CHAIN: GOOD_URL})188189    assert verifier._get_web3(GOOD_CHAIN) is not None190    with pytest.raises(ValueError, match="No verified RPC endpoint"):191        verifier._get_web3(SAPPHIRE_CHAIN)
+702 newtest/py/test_same_chain_e2e.py
  • The biggest addition: one chain acting as both accounting chain and deposit source. Real service objects (verifier, processor, sweep engine, withdrawal processor) over a mocked node whose lookups answer by hash. Covers deposit → gas funding → sweep → credit (ERC-20 and native), per-chain floors, the same-chain gas-funding hazard, withdrawal resolution and high-water marks, and fail-closed refusal of a configured-but-unverified chain. Mutation-checked to prove the assertions track injected config, not shipped defaults.
Show diff · +638 −0
new file mode 100644--- /dev/null+++ b/test/py/test_same_chain_e2e.py@@ -0,0 +1,638 @@1"""Same-chain E2E: single chain acts as both accounting and deposit source chain.23Uses mocked node responding by hash, with injected ChainConfig overrides.4"""56from types import SimpleNamespace7from unittest.mock import AsyncMock, MagicMock, patch89import pytest10from eth_abi import encode11from web3 import Web312from web3.exceptions import TransactionNotFound1314import src.services.deposit_processor as deposit_processor15import src.services.deposit_verifier as deposit_verifier16import src.services.rpc_identity as rpc_identity17import src.services.sweep_engine as sweep_engine18from src.config.chain_config import (19    DEFAULT_FINALITY_DEPTH,20    TRANSFER_EVENT_TOPIC,21    ChainConfig,22    L2Type,23)24from src.models.private_read import PrivateReadAuth25from src.services.deposit_processor import DepositProcessor26from src.services.deposit_verifier import DepositVerifier27from src.services.rpc_identity import initialize_verified_chain_rpc_urls28from src.services.sweep_engine import SweepEngine29from src.services.withdrawal_processor import WithdrawalProcessor3031SAME_CHAIN_ID = 2329332SAME_CHAIN_RPC_URL = "https://sapphire-localnet.example.invalid"33# Unverified second chain used to test fail-closed behavior.34FOREIGN_CHAIN_ID = 2329535FOREIGN_RPC_URL = "https://testnet.sapphire.example.invalid"3637DEPOSIT_ADDRESS = Web3.to_checksum_address("0x" + "a1" * 20)38BENEFICIARY = Web3.to_checksum_address("0x" + "b2" * 20)39TOKEN_ADDRESS = Web3.to_checksum_address("0x" + "c3" * 20)40GAS_TANK_ADDRESS = Web3.to_checksum_address("0x" + "d4" * 20)41WITHDRAWAL_TO_ADDRESS = Web3.to_checksum_address("0x" + "e5" * 20)42EVM_SIGNER_ADDRESS = Web3.to_checksum_address("0x" + "f6" * 20)4344ERC20_DEPOSIT_TX = "0x" + "11" * 3245NATIVE_DEPOSIT_TX = "0x" + "22" * 3246GAS_FUNDING_TX_HASH = b"\xa0" * 3247SWEEP_TX_HASH = b"\xb0" * 3248WITHDRAWAL_SIGNED_TX = b"\xc0" * 6449WITHDRAWAL_TX_HASH = "0x" + "d0" * 325051DEPOSIT_BLOCK = 10052LATEST_BLOCK = 11053SAPPHIRE_GAS_PRICE = 100_000_000_0005455ONE_HONOR = 10**1856TWO_ROSE = 2 * 10**185758PRIVATE_READ_AUTH = PrivateReadAuth(token=b"\x00" * 65, user_address=BENEFICIARY)596061class _AwaitableValue:62    def __init__(self, val):63        self._val = val6465    def __await__(self):66        if False:67            yield  # makes this a generator68        return self._val697071def _hash_key(value) -> str:72    if isinstance(value, (bytes, bytearray)):73        return "0x" + bytes(value).hex()74    return str(value).lower()757677def _hash_lookup(known: dict) -> AsyncMock:78    """Async mock shaped like an EVM node: answers only for hashes it knows."""7980    def _lookup(tx_hash, *args, **kwargs):81        key = _hash_key(tx_hash)82        if key not in known:83            raise TransactionNotFound(f"{tx_hash!r} not found")84        return known[key]8586    return AsyncMock(side_effect=_lookup)878889def _erc20_transfer_log(amount: int, log_index: int = 3) -> dict:90    return {91        "address": TOKEN_ADDRESS,92        "topics": [93            bytes.fromhex(TRANSFER_EVENT_TOPIC[2:]),94            bytes(12) + bytes.fromhex("de" * 20),95            bytes(12) + bytes.fromhex(DEPOSIT_ADDRESS[2:]),96        ],97        "data": amount.to_bytes(32, "big"),98        "logIndex": log_index,99    }100101102def _single_chain_node(103    *,104    receipts: dict,105    transactions: dict | None = None,106    native_balance: int = 0,107    broadcast_hashes: tuple[bytes, ...] = (),108) -> AsyncMock:109    """Mock node answering transaction lookups by hash rather than call order."""110    receipts = {_hash_key(k): v for k, v in receipts.items()}111    transactions = {_hash_key(k): v for k, v in (transactions or {}).items()}112    for offset, tx_hash in enumerate(broadcast_hashes, start=1):113        receipts.setdefault(_hash_key(tx_hash), {"status": 1, "blockNumber": LATEST_BLOCK + offset})114115    node = AsyncMock()116    node.eth.get_block = AsyncMock(117        side_effect=lambda _block="latest": {118            "number": LATEST_BLOCK,119            "baseFeePerGas": SAPPHIRE_GAS_PRICE,120        }121    )122    node.eth.gas_price = _AwaitableValue(SAPPHIRE_GAS_PRICE)123    node.eth.get_transaction_receipt = _hash_lookup(receipts)124    node.eth.get_transaction = _hash_lookup(transactions)125    node.eth.get_balance = AsyncMock(return_value=native_balance)126    node.eth.get_transaction_count = AsyncMock(return_value=0)127    node.eth.send_raw_transaction = AsyncMock(side_effect=list(broadcast_hashes))128    return node129130131# ─── Injected configuration ─────────────────────────────────────────────132133134@pytest.fixture135def same_chain_config() -> ChainConfig:136    return ChainConfig(137        chain_id=SAME_CHAIN_ID,138        finality_depth=2,139        min_deposit_native_wei=10_000_000_000_000_000,140        min_deposit_erc20_wei=ONE_HONOR,141        gas_funding_amount_wei=6_500_000_000_000_000,142        l2_type=L2Type.NONE,143        discovery_scan_chunk_blocks=100,144        discovery_lookback_blocks=640,145        discovery_max_lookback_blocks=3_800,146    )147148149@pytest.fixture(autouse=True)150def injected_chain(monkeypatch, same_chain_config) -> ChainConfig:151    """Override module-level minimums and lookups with isolated test config."""152    cfg = same_chain_config153    monkeypatch.setattr(154        deposit_processor,155        "MIN_DEPOSIT_NATIVE_WEI",156        {cfg.chain_id: cfg.min_deposit_native_wei},157    )158    monkeypatch.setattr(159        deposit_processor,160        "MIN_DEPOSIT_ERC20_WEI",161        {cfg.chain_id: cfg.min_deposit_erc20_wei},162    )163    monkeypatch.setattr(164        sweep_engine,165        "GAS_FUNDING_AMOUNT_WEI",166        {cfg.chain_id: cfg.gas_funding_amount_wei},167    )168    monkeypatch.setattr(169        deposit_verifier,170        "get_finality_depth",171        lambda chain_id: cfg.finality_depth if chain_id == cfg.chain_id else DEFAULT_FINALITY_DEPTH,172    )173174    async def _no_l1_data_fee(w3, chain_id, is_erc20):175        # Sapphire posts no calldata to L1; gas funding is purely L2 execution.176        assert chain_id == cfg.chain_id177        assert cfg.l2_type is L2Type.NONE178        return 0179180    monkeypatch.setattr(sweep_engine, "estimate_l1_data_fee", _no_l1_data_fee)181    return cfg182183184@pytest.fixture185def token_registry() -> dict:186    """Map (chain_id, token_address) to keccak token ID; None key denotes native token."""187    return {188        (SAME_CHAIN_ID, None): Web3.keccak(189            encode(["uint256", "address"], [SAME_CHAIN_ID, "0x" + "00" * 20])190        ),191        (SAME_CHAIN_ID, TOKEN_ADDRESS.lower()): Web3.keccak(192            encode(["uint256", "address"], [SAME_CHAIN_ID, TOKEN_ADDRESS])193        ),194    }195196197@pytest.fixture198def mock_accounting(token_registry) -> AsyncMock:199200    async def get_token_id(chain_id: int, token_address: str | None) -> bytes:201        key = (chain_id, token_address.lower() if token_address else None)202        if key not in token_registry:203            raise ValueError(f"token {token_address} not registered for chain {chain_id}")204        return token_registry[key]205206    svc = AsyncMock()207    svc.get_deposit_address = AsyncMock(return_value=DEPOSIT_ADDRESS)208    svc.get_token_id = AsyncMock(side_effect=get_token_id)209    svc.is_token_registered = AsyncMock(210        side_effect=lambda token_id: token_id in set(token_registry.values())211    )212    svc.is_deposit_processed = AsyncMock(return_value=False)213    svc.get_gas_tank_address = AsyncMock(return_value=GAS_TANK_ADDRESS)214    svc.generate_gas_funding_tx = AsyncMock(return_value=b"\x01gas")215    svc.generate_sweep_native = AsyncMock(return_value=b"\x02native")216    svc.generate_sweep_erc20 = AsyncMock(return_value=b"\x03erc20")217    svc.credit_deposit = AsyncMock()218    return svc219220221@pytest.fixture222def engine(tmp_path, mock_accounting) -> SweepEngine:223    return SweepEngine(224        accounting_service=mock_accounting,225        chain_rpc_urls={SAME_CHAIN_ID: SAME_CHAIN_RPC_URL},226        state_dir=str(tmp_path),227    )228229230@pytest.fixture231def verifier() -> DepositVerifier:232    return DepositVerifier({SAME_CHAIN_ID: SAME_CHAIN_RPC_URL})233234235@pytest.fixture236def processor(verifier, engine, mock_accounting) -> DepositProcessor:237    return DepositProcessor(238        verifier=verifier,239        sweep_engine=engine,240        accounting_service=mock_accounting,241    )242243244def _serve_node(verifier: DepositVerifier, engine: SweepEngine, node: AsyncMock):245    return (246        patch.object(verifier, "_get_web3", return_value=node),247        patch.object(engine, "_get_web3", return_value=node),248    )249250251async def _drain_background_sweeps(processor: DepositProcessor) -> None:252    await processor.stop()253254255# ─── Deposit → sweep → credit, all on the accounting chain ──────────────256257258@pytest.mark.asyncio259async def test_erc20_deposit_on_the_accounting_chain_is_verified_swept_and_credited(260    processor, verifier, engine, mock_accounting, injected_chain261):262    node = _single_chain_node(263        receipts={264            ERC20_DEPOSIT_TX: {265                "status": 1,266                "blockNumber": DEPOSIT_BLOCK,267                "to": TOKEN_ADDRESS,268                "logs": [_erc20_transfer_log(ONE_HONOR)],269            }270        },271        broadcast_hashes=(GAS_FUNDING_TX_HASH, SWEEP_TX_HASH),272    )273    verifier_patch, engine_patch = _serve_node(verifier, engine, node)274275    with (276        verifier_patch,277        engine_patch,278        patch.object(engine, "_get_erc20_balance", new_callable=AsyncMock, return_value=ONE_HONOR),279    ):280        result = await processor.process_deposit(281            chain_type="evm",282            chain_id=SAME_CHAIN_ID,283            tx_hash=ERC20_DEPOSIT_TX,284            amount=ONE_HONOR,285            log_index=3,286            version=0,287            auth=PRIVATE_READ_AUTH,288        )289290        assert result["status"] == "pending"291        assert result["token_address"] == TOKEN_ADDRESS292        deposit_id_hex = result["deposit_id"]293294        await _drain_background_sweeps(processor)295296    mock_accounting.get_token_id.assert_awaited_once_with(SAME_CHAIN_ID, TOKEN_ADDRESS)297    sweep_kwargs = mock_accounting.generate_sweep_erc20.await_args.kwargs298    assert sweep_kwargs["chain_id"] == SAME_CHAIN_ID299    assert sweep_kwargs["token_address"] == TOKEN_ADDRESS300    assert sweep_kwargs["amount"] == ONE_HONOR301302    credit_kwargs = mock_accounting.credit_deposit.await_args.kwargs303    assert credit_kwargs["beneficiary"] == BENEFICIARY304    assert credit_kwargs["amount"] == ONE_HONOR305    assert credit_kwargs["token_id"] == await mock_accounting.get_token_id(306        SAME_CHAIN_ID, TOKEN_ADDRESS307    )308    assert "0x" + credit_kwargs["deposit_id"].hex() == deposit_id_hex309310    assert engine.get_record_by_deposit_id(deposit_id_hex) is None311312313@pytest.mark.asyncio314async def test_erc20_sweep_funds_gas_with_the_injected_chain_amount(315    processor, verifier, engine, mock_accounting, injected_chain316):317    """Gas funding uses the configured amount rather than the unconfigured fallback."""318    node = _single_chain_node(319        receipts={320            ERC20_DEPOSIT_TX: {321                "status": 1,322                "blockNumber": DEPOSIT_BLOCK,323                "to": TOKEN_ADDRESS,324                "logs": [_erc20_transfer_log(ONE_HONOR)],325            }326        },327        broadcast_hashes=(GAS_FUNDING_TX_HASH, SWEEP_TX_HASH),328    )329    verifier_patch, engine_patch = _serve_node(verifier, engine, node)330331    with (332        verifier_patch,333        engine_patch,334        patch.object(engine, "_get_erc20_balance", new_callable=AsyncMock, return_value=ONE_HONOR),335    ):336        await processor.process_deposit(337            chain_type="evm",338            chain_id=SAME_CHAIN_ID,339            tx_hash=ERC20_DEPOSIT_TX,340            amount=ONE_HONOR,341            log_index=3,342            version=0,343            auth=PRIVATE_READ_AUTH,344        )345        await _drain_background_sweeps(processor)346347    gas_kwargs = mock_accounting.generate_gas_funding_tx.await_args.kwargs348    assert gas_kwargs["chain_id"] == SAME_CHAIN_ID349    assert gas_kwargs["to_deposit_address"] == DEPOSIT_ADDRESS350    assert gas_kwargs["gas_amount"] == injected_chain.gas_funding_amount_wei351    assert gas_kwargs["gas_amount"] != 200_000_000_000_000352    assert gas_kwargs["gas_price"] == SAPPHIRE_GAS_PRICE353    node.eth.get_transaction_count.assert_any_await(GAS_TANK_ADDRESS, "pending")354355356@pytest.mark.asyncio357async def test_native_deposit_on_the_accounting_chain_is_swept_and_credited(358    processor, verifier, engine, mock_accounting, injected_chain359):360    node = _single_chain_node(361        receipts={362            NATIVE_DEPOSIT_TX: {363                "status": 1,364                "blockNumber": DEPOSIT_BLOCK,365                "to": DEPOSIT_ADDRESS,366                "logs": [],367            }368        },369        transactions={370            NATIVE_DEPOSIT_TX: {371                "to": DEPOSIT_ADDRESS,372                "value": TWO_ROSE,373                "from": "0x" + "de" * 20,374            }375        },376        native_balance=TWO_ROSE,377        broadcast_hashes=(GAS_FUNDING_TX_HASH, SWEEP_TX_HASH),378    )379    verifier_patch, engine_patch = _serve_node(verifier, engine, node)380381    with verifier_patch, engine_patch:382        result = await processor.process_deposit(383            chain_type="evm",384            chain_id=SAME_CHAIN_ID,385            tx_hash=NATIVE_DEPOSIT_TX,386            amount=TWO_ROSE,387            log_index=0,388            version=0,389            auth=PRIVATE_READ_AUTH,390        )391        assert result["status"] == "pending"392        assert result["token_address"] is None393        await _drain_background_sweeps(processor)394395    mock_accounting.get_token_id.assert_awaited_once_with(SAME_CHAIN_ID, None)396    sweep_kwargs = mock_accounting.generate_sweep_native.await_args.kwargs397    assert sweep_kwargs["chain_id"] == SAME_CHAIN_ID398    assert sweep_kwargs["amount"] == TWO_ROSE399    gas_kwargs = mock_accounting.generate_gas_funding_tx.await_args.kwargs400    assert gas_kwargs["gas_amount"] == injected_chain.gas_funding_amount_wei401402    credit_kwargs = mock_accounting.credit_deposit.await_args.kwargs403    assert credit_kwargs["amount"] == TWO_ROSE404    assert credit_kwargs["beneficiary"] == BENEFICIARY405    assert engine.get_record_by_deposit_id(result["deposit_id"]) is None406407408@pytest.mark.asyncio409async def test_deposit_below_the_injected_erc20_floor_is_rejected(410    processor, verifier, engine, injected_chain411):412    short = injected_chain.min_deposit_erc20_wei - 1413    node = _single_chain_node(414        receipts={415            ERC20_DEPOSIT_TX: {416                "status": 1,417                "blockNumber": DEPOSIT_BLOCK,418                "to": TOKEN_ADDRESS,419                "logs": [_erc20_transfer_log(short)],420            }421        },422    )423    verifier_patch, engine_patch = _serve_node(verifier, engine, node)424425    with verifier_patch, engine_patch, pytest.raises(ValueError, match="minimum"):426        await processor.process_deposit(427            chain_type="evm",428            chain_id=SAME_CHAIN_ID,429            tx_hash=ERC20_DEPOSIT_TX,430            amount=short,431            log_index=3,432            version=0,433            auth=PRIVATE_READ_AUTH,434        )435436437@pytest.mark.asyncio438async def test_gas_funding_tx_cannot_be_claimed_as_a_deposit_on_the_same_chain(439    processor, verifier, engine, injected_chain440):441    """Native transfer to deposit address from gas tank must not trigger self-deposit."""442    node = _single_chain_node(443        receipts={444            ERC20_DEPOSIT_TX: {445                "status": 1,446                "blockNumber": DEPOSIT_BLOCK,447                "to": TOKEN_ADDRESS,448                "logs": [_erc20_transfer_log(ONE_HONOR)],449            }450        },451        broadcast_hashes=(GAS_FUNDING_TX_HASH, SWEEP_TX_HASH),452    )453    verifier_patch, engine_patch = _serve_node(verifier, engine, node)454455    with (456        verifier_patch,457        engine_patch,458        patch.object(engine, "_get_erc20_balance", new_callable=AsyncMock, return_value=ONE_HONOR),459    ):460        await processor.process_deposit(461            chain_type="evm",462            chain_id=SAME_CHAIN_ID,463            tx_hash=ERC20_DEPOSIT_TX,464            amount=ONE_HONOR,465            log_index=3,466            version=0,467            auth=PRIVATE_READ_AUTH,468        )469        await _drain_background_sweeps(processor)470471        gas_tx_hex = "0x" + GAS_FUNDING_TX_HASH.hex()472        assert gas_tx_hex.lower() in engine.gas_funding_tx_hashes473474        with pytest.raises(ValueError, match="[Gg]as funding"):475            await processor.process_deposit(476                chain_type="evm",477                chain_id=SAME_CHAIN_ID,478                tx_hash=gas_tx_hex,479                amount=injected_chain.gas_funding_amount_wei,480                log_index=0,481                version=0,482                auth=PRIVATE_READ_AUTH,483            )484485486# ─── Withdrawal back out over the same chain ────────────────────────────487488489@pytest.fixture490def withdrawal_accounting() -> MagicMock:491    service = MagicMock()492    service.get_all_pending_withdrawals = AsyncMock(493        return_value={"pending": [], "current_block": LATEST_BLOCK}494    )495    service.resolve_withdrawal = AsyncMock(return_value=MagicMock(status="submitted"))496    service._send_raw_transaction = AsyncMock(return_value=WITHDRAWAL_TX_HASH)497498    reader = MagicMock()499    reader.functions.withdrawals.return_value.call = AsyncMock(500        return_value=(501            BENEFICIARY,502            WITHDRAWAL_TO_ADDRESS,503            TWO_ROSE,504            DEPOSIT_BLOCK,505            b"\x00" * 32,506            True,507            encode(["uint64"], [0]),508        )509    )510    reader.functions.resolveWithdrawal.return_value.call = AsyncMock(511        return_value=WITHDRAWAL_SIGNED_TX512    )513    reader.functions.withdrawalCount.return_value.call = AsyncMock(return_value=1)514    service._get_reader_contract = MagicMock(return_value=reader)515    service._get_token_context = AsyncMock(return_value=SimpleNamespace(chain_id=SAME_CHAIN_ID))516    return service517518519def _build_withdrawal_processor(520    withdrawal_accounting: MagicMock, chain_rpc_urls: dict[int, str]521) -> WithdrawalProcessor:522    settings = MagicMock(523        withdrawal_poll_interval=1,524        withdrawal_resolution_timeout=1,525        sapphire_rpc_url=SAME_CHAIN_RPC_URL,526        accounting_contract_address="0x" + "ab" * 20,527        chain_rpc_urls=dict(chain_rpc_urls),528    )529    contract = MagicMock()530    contract.functions.evmAddress.return_value.call = AsyncMock(return_value=EVM_SIGNER_ADDRESS)531    contract.functions.nonces.return_value.call = AsyncMock(return_value=0)532533    with (534        patch(535            "src.services.withdrawal_processor.load_settings",536            return_value=settings,537        ),538        patch(539            "src.services.withdrawal_processor.AccountingContractService",540            return_value=withdrawal_accounting,541        ),542        patch("src.services.withdrawal_processor.AsyncWeb3") as mock_async_web3,543    ):544        sapphire = MagicMock()545        sapphire.eth.contract.return_value = contract546        mock_async_web3.return_value = sapphire547        proc = WithdrawalProcessor()548549    proc.accounting_service = withdrawal_accounting550    proc._contract = contract551    return proc552553554@pytest.mark.asyncio555async def test_withdrawal_to_the_accounting_chain_resolves_and_broadcasts(withdrawal_accounting):556    """Withdrawal signed on SAME_CHAIN_ID resolves and broadcasts back to SAME_CHAIN_ID."""557    processor = _build_withdrawal_processor(558        withdrawal_accounting, {SAME_CHAIN_ID: SAME_CHAIN_RPC_URL}559    )560    node = _single_chain_node(receipts={})561    processor._destination_web3[SAME_CHAIN_ID] = node562    processor._is_running = True563564    await processor._process_chain(565        SAME_CHAIN_ID,566        [{"index": 0, "chain_id": SAME_CHAIN_ID, "block_number": DEPOSIT_BLOCK}],567    )568569    processor._contract.functions.nonces.assert_any_call(SAME_CHAIN_ID)570    node.eth.get_transaction_count.assert_any_await(EVM_SIGNER_ADDRESS, "pending")571    withdrawal_accounting._send_raw_transaction.assert_awaited_once_with(572        SAME_CHAIN_ID, WITHDRAWAL_SIGNED_TX573    )574    assert processor._chain_high_water_mark[SAME_CHAIN_ID] == 0575576577@pytest.mark.asyncio578async def test_pending_withdrawal_for_the_accounting_chain_is_grouped_and_eligible(579    withdrawal_accounting,580):581    processor = _build_withdrawal_processor(582        withdrawal_accounting, {SAME_CHAIN_ID: SAME_CHAIN_RPC_URL}583    )584    withdrawal_accounting.get_all_pending_withdrawals.return_value = {585        "pending": [586            {"index": 0, "block_number": DEPOSIT_BLOCK, "chain_id": SAME_CHAIN_ID},587            {"index": 1, "block_number": LATEST_BLOCK, "chain_id": SAME_CHAIN_ID},588        ],589        "current_block": LATEST_BLOCK,590    }591592    pending = await processor._get_pending_withdrawals()593594    assert list(pending) == [SAME_CHAIN_ID]595    assert [w["index"] for w in pending[SAME_CHAIN_ID]] == [0]596597598# ─── One verified endpoint, both roles ──────────────────────────────────599600601@pytest.mark.asyncio602async def test_one_verified_endpoint_serves_deposits_sweeps_and_withdrawals(603    monkeypatch, mock_accounting, withdrawal_accounting, tmp_path604):605    """Single verified endpoint is shared across verifier, engine, and withdrawals."""606607    async def probe(url: str, timeout: float) -> int:608        if url == SAME_CHAIN_RPC_URL:609            return SAME_CHAIN_ID610        raise ConnectionError("localnet has no second chain")611612    monkeypatch.setattr(rpc_identity, "_probe_chain_id", probe)613614    configured = {SAME_CHAIN_ID: SAME_CHAIN_RPC_URL, FOREIGN_CHAIN_ID: FOREIGN_RPC_URL}615    served = await initialize_verified_chain_rpc_urls(616        SimpleNamespace(chain_rpc_urls=dict(configured))617    )618    assert served == {SAME_CHAIN_ID: SAME_CHAIN_RPC_URL}619620    verifier = DepositVerifier(dict(configured))621    engine = SweepEngine(622        accounting_service=mock_accounting,623        chain_rpc_urls=dict(configured),624        state_dir=str(tmp_path),625    )626    withdrawals = _build_withdrawal_processor(withdrawal_accounting, configured)627628    client = verifier._get_web3(SAME_CHAIN_ID)629    assert engine._get_web3(SAME_CHAIN_ID) is client630    assert withdrawals._get_destination_web3(SAME_CHAIN_ID) is client631632    for resolve in (633        lambda: verifier._get_web3(FOREIGN_CHAIN_ID),634        lambda: engine._get_web3(FOREIGN_CHAIN_ID),635        lambda: withdrawals._get_destination_web3(FOREIGN_CHAIN_ID),636    ):637        with pytest.raises(ValueError, match="No verified RPC endpoint"):638            resolve()
+348test/py/test_withdrawals.py
  • Nonce-readiness gate (refuses / proceeds / allows contract-ahead); duplicate-broadcast confirmed by receipt or pending tx for the exact hash; foreign-nonce duplicate not resolved and high-water mark not advanced; admission thresholds derived from gas price × contract gas limit.
Show diff · +326 −22
--- a/test/py/test_withdrawals.py+++ b/test/py/test_withdrawals.py@@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, patch1 2 import pytest3 from eth_abi import encode4from web3.exceptions import TransactionNotFound5 6from src.services.accounting_contract import AccountingContractService7 from src.services.withdrawal_processor import WithdrawalProcessor8 9 TEST_USER_ADDRESS = "0x1234567890123456789012345678901234567890"@@ -12,6 +14,31 @@ TEST_TO_ADDRESS = "0x9876543210987654321098765432109876543210"1 TEST_CHAIN_ID = 845322 TEST_TX_HASH = "0x" + "ab" * 323 TEST_SIGNED_TX = b"\x00" * 644TEST_OTHER_SIGNED_TX = b"\xff" * 64567def _hash_lookup(known: dict) -> AsyncMock:8    """Mock node lookup answering only for known hashes."""910    def _lookup(tx_hash):11        if tx_hash not in known:12            raise TransactionNotFound(f"{tx_hash} not found")13        return known[tx_hash]1415    return AsyncMock(side_effect=_lookup)161718def _resolved_withdrawal(nonce: int = 0) -> tuple:19    """withdrawals(index) tuple: (user, to, amount, block, tokenId, resolved, txId)."""20    return (21        TEST_USER_ADDRESS,22        TEST_TO_ADDRESS,23        100,24        50,25        b"\x00" * 32,26        True,27        encode(["uint64"], [nonce]),28    )29 30 31 class TestWithdrawalProcessor:@@ -258,26 +285,74 @@ class TestWithdrawalProcessor:1         processor.accounting_service._send_raw_transaction.assert_not_called()2 3     @pytest.mark.asyncio4    async def test_resolve_and_broadcast_nonce_too_low(self, processor):5        """Test that 'nonce too low' error is treated as success."""6    async def test_duplicate_broadcast_confirmed_by_receipt_succeeds(self, processor):7        """A rejected re-broadcast counts as success only when a receipt exists for8        the exact signed transaction's hash."""9         contract_reader = processor.accounting_service._get_reader_contract()10         contract_reader.functions.withdrawals.return_value.call.return_value = (11            TEST_USER_ADDRESS,12            TEST_TO_ADDRESS,13            100,14            50,15            b"\x00" * 32,16            True,17            encode(["uint64"], [0]),18            _resolved_withdrawal()19         )20         contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX21        processor.accounting_service._send_raw_transaction.side_effect = Exception("nonce too low")22        # Oasis wording, not geth's "nonce too low" - the error text must not matter23        processor.accounting_service._send_raw_transaction.side_effect = Exception("invalid nonce")24 25        withdrawal = {"index": 0, "chain_id": TEST_CHAIN_ID}26        result = await processor._resolve_and_broadcast(withdrawal)27        expected_hash = WithdrawalProcessor._expected_tx_hash(TEST_SIGNED_TX)28        mock_dest_web3 = MagicMock()29        mock_dest_web3.eth.get_transaction_receipt = _hash_lookup({expected_hash: {"status": 1}})30        mock_dest_web3.eth.get_transaction = _hash_lookup({})31        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web33233        with patch("src.services.withdrawal_processor.asyncio.sleep", new_callable=AsyncMock):34            result = await processor._resolve_and_broadcast({"index": 0, "chain_id": TEST_CHAIN_ID})35 36         assert result is True37         assert processor._chain_high_water_mark[TEST_CHAIN_ID] == 038        mock_dest_web3.eth.get_transaction_receipt.assert_any_await(expected_hash)3940    @pytest.mark.asyncio41    async def test_duplicate_broadcast_confirmed_by_pending_tx_succeeds(self, processor):42        """A mempool entry for the expected hash is also proof of a prior broadcast."""43        contract_reader = processor.accounting_service._get_reader_contract()44        contract_reader.functions.withdrawals.return_value.call.return_value = (45            _resolved_withdrawal()46        )47        contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX48        processor.accounting_service._send_raw_transaction.side_effect = Exception("already known")4950        expected_hash = WithdrawalProcessor._expected_tx_hash(TEST_SIGNED_TX)51        mock_dest_web3 = MagicMock()52        mock_dest_web3.eth.get_transaction_receipt = _hash_lookup({})53        mock_dest_web3.eth.get_transaction = _hash_lookup({expected_hash: {"hash": expected_hash}})54        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web35556        with patch("src.services.withdrawal_processor.asyncio.sleep", new_callable=AsyncMock):57            result = await processor._resolve_and_broadcast({"index": 0, "chain_id": TEST_CHAIN_ID})5859        assert result is True60        assert processor._chain_high_water_mark[TEST_CHAIN_ID] == 06162    @pytest.mark.asyncio63    async def test_nonce_spent_by_foreign_tx_is_not_resolved(self, processor):64        """A nonce burned by a different transaction must never read as a paid65        withdrawal."""66        contract_reader = processor.accounting_service._get_reader_contract()67        contract_reader.functions.withdrawals.return_value.call.return_value = (68            _resolved_withdrawal()69        )70        contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX71        processor.accounting_service._send_raw_transaction.side_effect = Exception("nonce too low")7273        foreign_hash = WithdrawalProcessor._expected_tx_hash(TEST_OTHER_SIGNED_TX)74        mock_dest_web3 = MagicMock()75        mock_dest_web3.eth.get_transaction_receipt = _hash_lookup({foreign_hash: {"status": 1}})76        mock_dest_web3.eth.get_transaction = _hash_lookup({foreign_hash: {"hash": foreign_hash}})77        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web37879        with patch("src.services.withdrawal_processor.asyncio.sleep", new_callable=AsyncMock):80            result = await processor._resolve_and_broadcast({"index": 0, "chain_id": TEST_CHAIN_ID})8182        assert result is False83        assert TEST_CHAIN_ID not in processor._chain_high_water_mark84 85     @pytest.mark.asyncio86     async def test_resolve_and_broadcast_invalid_chain_id(self, processor):@@ -359,26 +434,24 @@ class TestWithdrawalProcessor:1 2     @pytest.mark.asyncio3     async def test_catch_up_handles_already_broadcast(self, processor):4        """Test catch-up handles 'nonce too low' gracefully."""5        """Catch-up counts a rejected re-broadcast only when the chain has the exact6        signed transaction."""7         processor.settings.chain_rpc_urls = {TEST_CHAIN_ID: "https://example.com"}8 9         processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=2)10         processor._evm_address = TEST_USER_ADDRESS11 12        expected_hash = WithdrawalProcessor._expected_tx_hash(TEST_SIGNED_TX)13         mock_dest_web3 = MagicMock()14         mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=1)15        mock_dest_web3.eth.get_transaction_receipt = _hash_lookup({expected_hash: {"status": 1}})16        mock_dest_web3.eth.get_transaction = _hash_lookup({})17         processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web318 19         contract_reader = processor.accounting_service._get_reader_contract()20         contract_reader.functions.withdrawalCount.return_value.call.return_value = 121        contract_reader.functions.withdrawals.return_value.call.return_value = (22            TEST_USER_ADDRESS,23            TEST_TO_ADDRESS,24            100,25            50,26            b"\x00" * 32,27            True,28            encode(["uint64"], [1]),29        contract_reader.functions.withdrawals.return_value.call.return_value = _resolved_withdrawal(30            nonce=131         )32         contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX33 @@ -387,7 +460,238 @@ class TestWithdrawalProcessor:1         processor.accounting_service._get_token_context = AsyncMock(return_value=mock_context)2 3         # Simulate already broadcast4        processor.accounting_service._send_raw_transaction.side_effect = Exception("invalid nonce")56        with patch("src.services.withdrawal_processor.asyncio.sleep", new_callable=AsyncMock):7            await processor._catch_up_missing_broadcasts([TEST_CHAIN_ID])89        mock_dest_web3.eth.get_transaction_receipt.assert_any_await(expected_hash)1011    @pytest.mark.asyncio12    async def test_catch_up_does_not_trust_error_text_for_foreign_nonce(self, processor):13        """A duplicate-broadcast error is verified by hash, so a nonce spent elsewhere14        is never counted as broadcast."""15        processor.settings.chain_rpc_urls = {TEST_CHAIN_ID: "https://example.com"}1617        processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=2)18        processor._evm_address = TEST_USER_ADDRESS1920        expected_hash = WithdrawalProcessor._expected_tx_hash(TEST_SIGNED_TX)21        mock_dest_web3 = MagicMock()22        mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=1)23        mock_dest_web3.eth.get_transaction_receipt = _hash_lookup({})24        mock_dest_web3.eth.get_transaction = _hash_lookup({})25        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web32627        contract_reader = processor.accounting_service._get_reader_contract()28        contract_reader.functions.withdrawalCount.return_value.call.return_value = 129        contract_reader.functions.withdrawals.return_value.call.return_value = _resolved_withdrawal(30            nonce=131        )32        contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX3334        mock_context = MagicMock()35        mock_context.chain_id = TEST_CHAIN_ID36        processor.accounting_service._get_token_context = AsyncMock(return_value=mock_context)3738         processor.accounting_service._send_raw_transaction.side_effect = Exception("nonce too low")39 40        # Should not raise, just log and continue41        with patch("src.services.withdrawal_processor.asyncio.sleep", new_callable=AsyncMock):42            await processor._catch_up_missing_broadcasts([TEST_CHAIN_ID])4344        mock_dest_web3.eth.get_transaction_receipt.assert_any_await(expected_hash)45        mock_dest_web3.eth.get_transaction.assert_any_await(expected_hash)4647    @pytest.mark.asyncio48    async def test_process_chain_refuses_when_contract_nonce_behind_chain(self, processor):49        """A fresh chain whose evmAddress has already transacted must not be processed:50        the contract would sign nonces the chain has already spent."""51        processor._is_running = True52        processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=0)53        processor._evm_address = TEST_USER_ADDRESS5455        mock_dest_web3 = MagicMock()56        mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=3)57        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web35859        contract_reader = processor.accounting_service._get_reader_contract()6061        await processor._process_chain(TEST_CHAIN_ID, [{"index": 0, "chain_id": TEST_CHAIN_ID}])6263        contract_reader.functions.withdrawals.assert_not_called()64        processor.accounting_service.resolve_withdrawal.assert_not_called()65        processor.accounting_service._send_raw_transaction.assert_not_called()66        assert processor._chain_high_water_mark == {}67        # Pending count includes queued transactions that spent nonces68        mock_dest_web3.eth.get_transaction_count.assert_any_await(TEST_USER_ADDRESS, "pending")6970    @pytest.mark.asyncio71    async def test_catch_up_refuses_when_contract_nonce_behind_chain(self, processor):72        """The same gate stops the catch-up pass before it scans or broadcasts."""73        processor.settings.chain_rpc_urls = {TEST_CHAIN_ID: "https://example.com"}7475        processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=1)76        processor._evm_address = TEST_USER_ADDRESS7778        mock_dest_web3 = MagicMock()79        mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=4)80        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web38182        contract_reader = processor.accounting_service._get_reader_contract()8384         await processor._catch_up_missing_broadcasts([TEST_CHAIN_ID])8586        contract_reader.functions.withdrawalCount.assert_not_called()87        processor.accounting_service._send_raw_transaction.assert_not_called()8889    @pytest.mark.asyncio90    async def test_process_chain_proceeds_when_nonces_match(self, processor):91        """Matched nonces are the ready state: the chain processes normally."""92        processor._is_running = True93        processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=4)94        processor._evm_address = TEST_USER_ADDRESS9596        mock_dest_web3 = MagicMock()97        mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=4)98        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web399100        contract_reader = processor.accounting_service._get_reader_contract()101        contract_reader.functions.withdrawals.return_value.call.return_value = _resolved_withdrawal(102            nonce=4103        )104        contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX105        processor.accounting_service._send_raw_transaction.return_value = TEST_TX_HASH106107        await processor._process_chain(TEST_CHAIN_ID, [{"index": 7, "chain_id": TEST_CHAIN_ID}])108109        processor.accounting_service._send_raw_transaction.assert_called_once_with(110            TEST_CHAIN_ID, TEST_SIGNED_TX111        )112        assert processor._chain_high_water_mark[TEST_CHAIN_ID] == 7113114    @pytest.mark.asyncio115    async def test_process_chain_allows_contract_nonce_ahead_of_chain(self, processor):116        """The gate is one-directional: a contract nonce ahead of the chain is an117        un-broadcast backlog, not a hazard."""118        processor._is_running = True119        processor._contract.functions.nonces.return_value.call = AsyncMock(return_value=6)120        processor._evm_address = TEST_USER_ADDRESS121122        mock_dest_web3 = MagicMock()123        mock_dest_web3.eth.get_transaction_count = AsyncMock(return_value=4)124        processor._destination_web3[TEST_CHAIN_ID] = mock_dest_web3125126        contract_reader = processor.accounting_service._get_reader_contract()127        contract_reader.functions.withdrawals.return_value.call.return_value = _resolved_withdrawal(128            nonce=6129        )130        contract_reader.functions.resolveWithdrawal.return_value.call.return_value = TEST_SIGNED_TX131        processor.accounting_service._send_raw_transaction.return_value = TEST_TX_HASH132133        await processor._process_chain(TEST_CHAIN_ID, [{"index": 9, "chain_id": TEST_CHAIN_ID}])134135        processor.accounting_service._send_raw_transaction.assert_called_once()136        assert processor._chain_high_water_mark[TEST_CHAIN_ID] == 9137138139class TestWithdrawalAdmission:140    """Per-chain gas admission for withdrawals (`_check_destination_balance`)."""141142    SAPPHIRE_GAS_PRICE = 100_000_000_000  # 100 gwei, as published for chain 23295143    SAPPHIRE_CHAIN_ID = 23295144    ERC20_GAS_LIMIT = 100_000  # gasLimitERC20Withdraw145    NATIVE_GAS_LIMIT = 50_000  # gasLimitNativeWithdraw146    GLOBAL_FLOOR = 10_000_000_000_000  # MIN_WITHDRAWAL_GAS_BALANCE, only a floor147148    @staticmethod149    def _make_service(gas_price: int, balance: int, floor: int = 0) -> AccountingContractService:150        service = AccountingContractService.__new__(AccountingContractService)151152        reader = MagicMock()153        reader.functions.gasPrices.return_value.call = AsyncMock(return_value=gas_price)154        reader.functions.gasLimitERC20Withdraw.return_value.call = AsyncMock(155            return_value=TestWithdrawalAdmission.ERC20_GAS_LIMIT156        )157        reader.functions.gasLimitNativeWithdraw.return_value.call = AsyncMock(158            return_value=TestWithdrawalAdmission.NATIVE_GAS_LIMIT159        )160        service.contract_reader = reader161        service._withdrawal_gas_limits = {}162        service.settings = MagicMock(min_withdrawal_gas_balance=floor)163164        chain_w3 = MagicMock()165        chain_w3.eth.get_balance = AsyncMock(return_value=balance)166        service._get_chain_web3 = AsyncMock(return_value=chain_w3)167        service._get_deposit_address = AsyncMock(return_value=TEST_USER_ADDRESS)168        return service169170    @pytest.mark.asyncio171    async def test_rejects_balance_that_only_clears_the_global_floor(self):172        """1e13 wei clears the flat gas-balance floor but covers 0.1% of a 100 gwei173        ERC-20 withdrawal: 100_000 gas x 100 gwei = 1e16 wei, 1.2e16 with the buffer."""174        service = self._make_service(175            self.SAPPHIRE_GAS_PRICE, balance=self.GLOBAL_FLOOR, floor=self.GLOBAL_FLOOR176        )177178        with pytest.raises(ValueError, match="needs at least 12000000000000000 wei"):179            await service._check_destination_balance(180                self.SAPPHIRE_CHAIN_ID, is_native=False, amount=10**18181            )182183    @pytest.mark.asyncio184    async def test_admits_when_balance_covers_gas_price_times_gas_limit(self):185        service = self._make_service(186            self.SAPPHIRE_GAS_PRICE, balance=12_000_000_000_000_000, floor=self.GLOBAL_FLOOR187        )188189        await service._check_destination_balance(190            self.SAPPHIRE_CHAIN_ID, is_native=False, amount=10**18191        )192193    @pytest.mark.asyncio194    async def test_native_withdrawal_requires_gas_plus_amount(self):195        gas_required = self.SAPPHIRE_GAS_PRICE * self.NATIVE_GAS_LIMIT * 120 // 100196        amount = 5 * 10**16197        service = self._make_service(self.SAPPHIRE_GAS_PRICE, balance=gas_required + amount - 1)198        reader = service.contract_reader199200        with pytest.raises(ValueError, match="Insufficient native balance"):201            await service._check_destination_balance(202                self.SAPPHIRE_CHAIN_ID, is_native=True, amount=amount203            )204205        # Native withdrawals require the native gas limit, not ERC-20206        reader.functions.gasLimitNativeWithdraw.return_value.call.assert_awaited_once()207        reader.functions.gasLimitERC20Withdraw.return_value.call.assert_not_awaited()208209    @pytest.mark.asyncio210    async def test_min_withdrawal_gas_balance_remains_a_floor(self):211        """A near-zero published gas price still requires the configured floor."""212        service = self._make_service(1, balance=10**12, floor=self.GLOBAL_FLOOR)213214        with pytest.raises(ValueError, match="needs at least 10000000000000 wei"):215            await service._check_destination_balance(84532, is_native=False, amount=1)216217    @pytest.mark.asyncio218    async def test_rejects_chain_without_published_gas_price(self):219        """No gasPrices(chainId) means the contract cannot sign; admit nothing."""220        service = self._make_service(0, balance=10**18, floor=self.GLOBAL_FLOOR)221222        with pytest.raises(ValueError, match="No gas price published"):223            await service._check_destination_balance(224                self.SAPPHIRE_CHAIN_ID, is_native=False, amount=1225            )226227    @pytest.mark.asyncio228    async def test_gas_limit_is_read_from_the_contract_and_cached(self):229        """The limit comes from the contract getter, never a Python mirror."""230        service = self._make_service(self.SAPPHIRE_GAS_PRICE, balance=10**18)231        reader = service.contract_reader232233        for _ in range(2):234            await service._check_destination_balance(235                self.SAPPHIRE_CHAIN_ID, is_native=False, amount=1236            )237238        reader.functions.gasLimitERC20Withdraw.return_value.call.assert_awaited_once()239        assert service._withdrawal_gas_limits == {"gasLimitERC20Withdraw": self.ERC20_GAS_LIMIT}
+153solidity/test/EVMSignerAndVerifier.ts
  • Asserts gasLimitNativeSweep() == 25000 and that generated native-sweep and gas-funding transactions encode exactly 25,000 gas, via Sapphire signed queries (the generators are onlyROFLQuery-gated, so plain eth_call can't authenticate).
Show diff · +152 −0
--- a/solidity/test/EVMSignerAndVerifier.ts+++ b/solidity/test/EVMSignerAndVerifier.ts@@ -48,4 +48,156 @@ describe('EVMSignerAndVerifier', function () {1       ).to.be.reverted; // WithCustomError(mockEVMSignerAndVerifier, "InvalidGasPrice"); // https://github.com/oasisprotocol/sapphire-paratime/issues/6882     });3   });45  describe("gas limits and transaction generation", function () {6    it("should report gasLimitNativeSweep as 25000", async function () {7      expect(await mockEVMSignerAndVerifier.gasLimitNativeSweep()).to.equal(25000n);8    });910    it("should encode gasLimit 25000 in generated native sweep transaction", async function () {11      const network = await ethers.provider.getNetwork();12      if (network.chainId < 0x5afd || network.chainId > 0x5aff) {13        this.skip();14      }1516      const signer = (await ethers.getSigners())[0];17      await (await mockEVMSignerAndVerifier.setRoflSignerAddress(signer.address)).wait();1819      const beneficiary = (await ethers.getSigners())[1].address;20      const chainId = 23295n;21      const amount = ethers.parseEther("1.0");22      const gasPrice = 100000000000n; // 100 gwei23      const sourceChainNonce = 0n;2425      const contractAddress = await mockEVMSignerAndVerifier.getAddress();26      const mnemonic = 'chimney theory present latin find behave ankle clock shadow earn suit reflect';2728      // generateSweepNativeTransfer requires onlyROFLQuery: plain eth_call cannot authenticate29      // msg.sender, so this requires a Sapphire signed query (via sapphirepy).30      const script = `31import asyncio32from web3 import AsyncWeb3, AsyncHTTPProvider, Web333from eth_account import Account34from sapphirepy import sapphire3536Account.enable_unaudited_hdwallet_features()37acct = Account.from_mnemonic('${mnemonic}', account_path="m/44'/60'/0'/0/0")38w3 = AsyncWeb3(AsyncHTTPProvider('http://localhost:8545'))39wrapped = sapphire.wrap(w3, acct)40wrapped.eth.default_account = acct.address4142abi = [{43    'inputs': [44        {'name': 'beneficiary', 'type': 'address'},45        {'name': 'chainType', 'type': 'uint8'},46        {'name': 'version', 'type': 'uint256'},47        {'name': 'chainId', 'type': 'uint256'},48        {'name': 'amount', 'type': 'uint256'},49        {'name': 'sourceChainNonce', 'type': 'uint64'},50        {'name': 'gasPrice', 'type': 'uint256'}51    ],52    'name': 'generateSweepNativeTransfer',53    'outputs': [{'name': 'signedTx', 'type': 'bytes'}],54    'stateMutability': 'view',55    'type': 'function'56}]5758contract = wrapped.eth.contract(address=Web3.to_checksum_address('${contractAddress}'), abi=abi)5960async def run():61    res = await contract.functions.generateSweepNativeTransfer(62        Web3.to_checksum_address('${beneficiary}'),63        0,64        0,65        ${chainId},66        ${amount},67        ${sourceChainNonce},68        ${gasPrice}69    ).call()70    print(res.hex())7172asyncio.run(run())73`;74      const { execSync } = await import('child_process');75      const out = execSync(`uv run --active python -c "${script.replace(/"/g, '\\"')}"`, { cwd: '..' }).toString().trim();76      const signedTx = '0x' + out;7778      const parsedTx = ethers.Transaction.from(signedTx);79      expect(parsedTx.gasLimit).to.equal(25000n);80      expect(parsedTx.chainId).to.equal(chainId);81      expect(parsedTx.value).to.equal(amount);82      expect(parsedTx.gasPrice).to.equal(gasPrice);83      expect(parsedTx.to).to.equal(await mockEVMSignerAndVerifier.evmAddress());84    });8586    it("should encode gasLimit 25000 in generated gas funding transaction", async function () {87      const network = await ethers.provider.getNetwork();88      if (network.chainId < 0x5afd || network.chainId > 0x5aff) {89        this.skip();90      }9192      const signer = (await ethers.getSigners())[0];93      await (await mockEVMSignerAndVerifier.setRoflSignerAddress(signer.address)).wait();9495      const toDepositAddress = (await ethers.getSigners())[1].address;96      const chainId = 23295n;97      const gasAmount = 6500000000000000n; // 65000 * 100 gwei98      const gasTankNonce = 0n;99      const gasPrice = 100000000000n; // 100 gwei100101      const contractAddress = await mockEVMSignerAndVerifier.getAddress();102      const mnemonic = 'chimney theory present latin find behave ankle clock shadow earn suit reflect';103104      const script = `105import asyncio106from web3 import AsyncWeb3, AsyncHTTPProvider, Web3107from eth_account import Account108from sapphirepy import sapphire109110Account.enable_unaudited_hdwallet_features()111acct = Account.from_mnemonic('${mnemonic}', account_path="m/44'/60'/0'/0/0")112w3 = AsyncWeb3(AsyncHTTPProvider('http://localhost:8545'))113wrapped = sapphire.wrap(w3, acct)114wrapped.eth.default_account = acct.address115116abi = [{117    'inputs': [118        {'name': 'toDepositAddress', 'type': 'address'},119        {'name': 'chainId', 'type': 'uint256'},120        {'name': 'gasAmount', 'type': 'uint256'},121        {'name': 'gasTankNonce', 'type': 'uint64'},122        {'name': 'gasPrice', 'type': 'uint256'}123    ],124    'name': 'generateGasFundingTx',125    'outputs': [{'name': 'signedTx', 'type': 'bytes'}],126    'stateMutability': 'view',127    'type': 'function'128}]129130contract = wrapped.eth.contract(address=Web3.to_checksum_address('${contractAddress}'), abi=abi)131132async def run():133    res = await contract.functions.generateGasFundingTx(134        Web3.to_checksum_address('${toDepositAddress}'),135        ${chainId},136        ${gasAmount},137        ${gasTankNonce},138        ${gasPrice}139    ).call()140    print(res.hex())141142asyncio.run(run())143`;144      const { execSync } = await import('child_process');145      const out = execSync(`uv run --active python -c "${script.replace(/"/g, '\\"')}"`, { cwd: '..' }).toString().trim();146      const signedTx = '0x' + out;147148      const parsedTx = ethers.Transaction.from(signedTx);149      expect(parsedTx.gasLimit).to.equal(25000n);150      expect(parsedTx.chainId).to.equal(chainId);151      expect(parsedTx.value).to.equal(gasAmount);152      expect(parsedTx.gasPrice).to.equal(gasPrice);153      expect(parsedTx.to).to.equal(toDepositAddress);154    });155  });156 });
+48solidity/test/Accounting.E2E.ts
  • Proxy upgrade path: state written before an upgrade survives it, VERSION == 2 afterwards, re-initialization and direct-implementation initialization still rejected, V2-with-new-state upgrade shape supported.
Show diff · +48 −0
--- a/solidity/test/Accounting.E2E.ts+++ b/solidity/test/Accounting.E2E.ts@@ -1500,10 +1500,58 @@ describe('Upgradability', function () {1     expect(tokenInfoAfter.tokenType).to.equal(tokenInfoBefore.tokenType, "Token info should be preserved after upgrade");2     expect(tokenInfoAfter.data).to.equal(tokenInfoBefore.data, "Token data should be preserved after upgrade");3 4    expect(await upgraded.VERSION()).to.equal(2n, "VERSION should be 2 after upgrade");5     // Verify the proxy address is the same6     expect(await upgraded.getAddress()).to.equal(proxyAddress, "Proxy address should remain the same");7   });8 9  it("Should upgrade implementation, report VERSION == 2, and preserve prior state", async function () {10    const user = (await ethers.getSigners())[2];11    const freshProxy = await deployMockAccounting(await mockSiweAuth.getAddress());12    const freshProxyAddress = await freshProxy.getAddress();1314    expect(await freshProxy.VERSION()).to.equal(2n);1516    const data = ethers.concat([17      ethers.zeroPadValue(ethers.toBeHex(TEST_TOKEN.chainId), 32),18      ethers.zeroPadValue(TEST_TOKEN.address, 20)19    ]);20    await freshProxy.setTokenInfo({21      tokenType: TEST_TOKEN.tokenType,22      data: data23    });2425    const testBalance = parseUsdt("500");26    await freshProxy.setBalance(user.address, TEST_TOKEN.tokenId, testBalance);2728    const testChainId = 23295n;29    const testGasPrice = 100000000000n; // 100 gwei30    await freshProxy.setGasPrice(testChainId, testGasPrice);3132    const balanceBefore = await freshProxy.getBalance(user.address, TEST_TOKEN.tokenId);33    const ownerBefore = await freshProxy.owner();34    const gasPriceBefore = await freshProxy.gasPrices(testChainId);35    const tokenInfoBefore = await freshProxy.tokens(TEST_TOKEN.tokenId);3637    const MockAccountingFactory = await ethers.getContractFactory('MockAccounting');38    const newImplAddress = await upgrades.prepareUpgrade(freshProxyAddress, MockAccountingFactory, {39      kind: 'uups',40      constructorArgs: [await mockSiweAuth.getAddress()],41      redeployImplementation: 'always',42    }) as string;4344    await (await freshProxy.proposeUpgrade(newImplAddress, 0)).wait();45    await (await freshProxy.upgradeToAndCall(newImplAddress, "0x")).wait();4647    const upgraded = (await ethers.getContractFactory('MockAccounting')).attach(freshProxyAddress) as unknown as MockAccounting;4849    expect(await upgraded.VERSION()).to.equal(2n, "VERSION must report 2");50    expect(await upgraded.getBalance(user.address, TEST_TOKEN.tokenId)).to.equal(balanceBefore, "User balance must be preserved");51    expect(await upgraded.owner()).to.equal(ownerBefore, "Contract owner must be preserved");52    expect(await upgraded.gasPrices(testChainId)).to.equal(gasPriceBefore, "Chain gas price must be preserved");53    expect((await upgraded.tokens(TEST_TOKEN.tokenId)).data).to.equal(tokenInfoBefore.data, "Token data must be preserved");54  });5556   it("Should only allow owner to upgrade", async function () {57     const attacker = getDeployer(1);58