Sapphire Testnet as a HONOR Deposit Source Chain
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
VERSIONis 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_chainIdcorrectly 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 nonceinstead, 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/addressused 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.
Security
New and changed assumptions
| Area | Assumption after this PR |
|---|---|
| RPC endpoints | New 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 broadcasts | Weaker 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 readiness | New 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 admission | Required 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 advertisement | Clients 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 constant | gasLimitNativeSweep 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 gating | VERSION = 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 resolveWithdrawal | Unchanged 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 chains | Accepted 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
resolveWithdrawalwas 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 upgrade→VERSION 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/addressadvertises 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/pendingscanned 639 blocks with no range error; withdrawal happy path paid out. - Front-run probe: signed withdrawal bytes obtained via permissionless
eth_calland broadcast externally; the node rejected the processor's re-broadcast withinvalid nonce, and the processor settled correctly on the exact expected hash. One payment, one balance debit.
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_weion 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 withinsufficient balance to pay fees. Decide whether to size with a buffer.
Deployment: open steps for live
- PR approval & merge (this branch).
- Contract upgrade:
cd solidity && npx hardhat upgrade --network sapphire-testnet --address 0x910CFfe4e8B27bc367F6E34D6D8e3C31DED68B6f; add--output-safeif the owner is a Safe. VerifyVERSION() == 2andgasLimitNativeSweep() == 25000afterwards. Must land before the ROFL redeploy (signing with the old limit fails every 23295 sweep). - Image: tag
privana/v1.0.3-testnet→ ci-docker builds and pushes the live image. - Compose: digest the new image into
compose.testnet.yaml, commit. - ROFL: bump
version:inrofl.yaml,oasis rofl build, commit, thenoasis rofl deploy --deployment testnet(adminptrus_testnet). - Live verification: confirm the live app's boot logs show 23295 verified/registered; fund checks for
evmAddress()andgasTankAddress()(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. - 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
_build_chain_rpc_urlsnow always seeds the Sapphire RPC (fromSAPPHIRE_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
@@ -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
- Two new
CHAIN_CONFIGSentries: 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/ERC20andGAS_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
@@ -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
POST /deposits/address: themin_depositmap now includes an asset type only when a token of that type is registered for the chain (derived fromsettings.token_infos). 23295 advertiseserc20only; 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
@@ -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):
DepositAddressResponse.min_depositdocumented/typed as omitting unsupported asset keys rather than always containing both, matching the routes change.
Show diff · +5 −1
@@ -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",
- Lifespan now runs the RPC identity verification first, before any service starts consuming
chain_rpc_urls.
Show diff · +7 −0
@@ -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
- New shared helper: verifies each configured RPC endpoint's
eth_chainId, caches the verified set, and exposesverified_web3(chain_id, …)that returnsNonefor 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
@@ -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()
- 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_chainso one misconfigured chain skips only itself; catch-up uses the same gate.
Show diff · +228 −84
@@ -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
- 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
@@ -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]:
- Web3 client construction goes through
rpc_identity.verified_web3; an unverified chain raisesDiscoveryNotConfiguredErrorinstead of scanning the wrong network.
Show diff · +20 −4
@@ -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(
- Same verified-client routing for deposit verification.
Show diff · +16 −6
@@ -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(
- Same verified-client routing for sweep/funding broadcasts.
Show diff · +15 −7
@@ -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
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×gasPricerewritten 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
@@ -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,
VERSION: 1 → 2. Required forhardhat upgradeto act (it skips when on-chain ≥ available).
Show diff · +2 −1
@@ -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;
- Minimal 18-decimal ERC-20 for the localnet same-chain dev path ("LHONOR"). Test contract, not deployed anywhere real.
Show diff · +27 −0
@@ -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
- 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 viavalidateUpgradeindeploy.ts. No false safety net.
Show diff · +12 −6
@@ -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 });
- After a localnet deploy, also deploys the deterministic localnet token (see
localnetToken.ts). No effect on testnet/mainnet paths.
Show diff · +8 −0
@@ -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) {
- Registers the new task module.
Show diff · +1 −0
@@ -1,4 +1,5 @@1 import "./deploy";2import "./localnetToken";3 import "./show";4 import "./tokens";5 import "./sign";
- Deploys
LocalnetERC20from 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.localnetentry therefore never rots.
Show diff · +130 −0
@@ -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
- 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) toACCOUNTING_GAS_PRICE, otherwise every 23295 submission reverts withGasPriceNotSet.
Show diff · +3 −2
@@ -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
- 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
@@ -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
- 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
@@ -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
- Compose for the rehearsal machine: digest-pinned image, no custom-domain annotation, build args pointed at
.env.rubetestso no accidental build targets the live proxy.
Show diff · +27 −0
@@ -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:
- Separate deployment entry for test rehearsals (own app ID, trust root, and compose). The live
testnetdeployment is untouched.
Show diff · +35 −0
@@ -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
- 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
@@ -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
- Regenerated spec drift from the
min_depositshape change.
Show diff · +1 −0
@@ -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
- 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
@@ -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."""
- 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
@@ -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"
- 23295 advertises ERC-20 only; 84532 advertises both;
/deposits/pendingaccepts 23295.
Show diff · +71 −0
@@ -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
- 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
@@ -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 )
- Match/mismatch/unreachable branches of the identity check; verified-set caching; reset semantics.
Show diff · +191 −0
@@ -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)
- 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
@@ -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()
- 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
@@ -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}
- Asserts
gasLimitNativeSweep() == 25000and that generated native-sweep and gas-funding transactions encode exactly 25,000 gas, via Sapphire signed queries (the generators areonlyROFLQuery-gated, so plaineth_callcan't authenticate).
Show diff · +152 −0
@@ -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 });
- Proxy upgrade path: state written before an upgrade survives it,
VERSION == 2afterwards, re-initialization and direct-implementation initialization still rejected, V2-with-new-state upgrade shape supported.
Show diff · +48 −0
@@ -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