Python orchestration layer for the Accounting Module. Runs as a FastAPI app inside a ROFL TEE, verifies cross-chain deposits, manages a sweep state machine for moving funds from per-user deposit addresses to the encumbered wallet, and resolves user withdrawals to broadcastable signed transactions.
| Topic | Location |
|---|---|
| HTTP API endpoints (request/response shapes, auth) | docs/api-reference.md |
| Solidity contracts, deployment, on-chain flows | solidity/README.md |
| Service internals — this file | continue below |
src/
├── main.py # FastAPI app + lifespan startup/shutdown
├── api/ # HTTP route handlers
│ ├── routes.py # Deposits, locks, transfers, withdrawals
│ └── auth_routes.py # SIWE login, OAuth-style code/token exchange
├── services/ # Business logic
│ ├── deposit_processor.py # Orchestrates verify → sweep → credit
│ ├── deposit_verifier.py # RPC-based source-chain verification
│ ├── sweep_engine.py # Per-deposit state machine, persistence, recovery
│ ├── withdrawal_processor.py# Polls Sapphire, resolves, broadcasts
│ ├── accounting_contract.py # Sapphire client (ROFL or direct-key path)
│ ├── onramp_intent.py # Signed intent codec + ROFL-derived key ring
│ ├── onramp.py # MoonPay adapter
│ ├── transak.py # Bounded Transak token/session/order adapter
│ ├── rofl_signer_bootstrap.py # Publish roflSignerAddress at startup
│ ├── gas_price_bootstrap.py # Sync per-chain gas prices at startup
│ ├── token_info_bootstrap.py # Register configured tokens at startup
│ ├── l2_fee_estimator.py # OP-stack / Arbitrum L1-data-fee estimation
│ └── cache.py # Lightweight in-memory caches
├── clients/
│ └── rofl.py # ROFL appd client (sign tx, fetch keypairs)
├── auth/ # SIWE + OAuth-style auth machinery
│ ├── siwe_service.py # SIWE message verification
│ ├── jwt_service.py # JWT issuance/validation
│ ├── auth_token_service.py # Encrypted SIWE token via AccountingSiweAuth
│ ├── jwt_keys.py # ROFL-derived JWT key manager
│ ├── auth_token_keys.py # ROFL-derived AuthToken key manager
│ ├── client_registry.py # Allowed OAuth clients
│ ├── code_store.py / token_store.py / pkce.py / rate_limiter.py
│ └── ...
├── config/
│ ├── __init__.py # Settings dataclass + env loading
│ └── chain_config.py # Per-chain finality, min-deposit, gas-funding
├── models/ # Pydantic request/response types
├── crypto/
│ └── deoxysii.py # Sapphire AuthToken decryption helper
├── abi/ # Contract ABIs (generated)
├── static/ # Landing-page assets
└── templates/ # FastAPI HTML templates (landing, authorize)
Triggered by POST /deposits/check. The processor returns within ~2-3 s with status="pending" and a deposit_id; the actual sweep continues in a background task. Clients poll GET /deposits/status/{deposit_id}.
Client DepositProcessor SweepEngine Sapphire
│ │ │ │
│── /deposits/check ─────────────▶│ │ │
│ (chain_id, tx_hash, amount, │ │ │
│ log_index, version, siwe) │ │ │
│ │ │ │
│ │── verify_deposit ───── (source-chain RPC) ─▶ │
│ │ (status=1, finality, │ │
│ │ matching Transfer/ │ │
│ │ tx.value) │ │
│ │ │ │
│ │── compute_deposit_id ────│ │
│ │── is_deposit_processed ─────────────────────────▶ │
│ │ │ │
│ │── create background ────▶│ │
│ │ sweep task │ │
│◀── {pending, deposit_id} ───────│ │ │
│ │ │ │
│ │ │── sweep_native/erc20 │
│ │ │ (state machine) │
│ │ │── creditDeposit ───────▶│
│ │ │ │
│── /deposits/status/{id} ───────▶│ (returns "credited" once on-chain, else "pending") │
Key files: services/deposit_processor.py, services/deposit_verifier.py, services/sweep_engine.py.
ONRAMP_PROVIDER selects either MoonPay or Transak for new launches. Both providers are thin correlation adapters around the same signed intent and deposit authority:
POST /onramp/intentbinds the authenticated user, freshly derived deposit address, registered token, chain, provider, and asset.- MoonPay uses
POST /onramp/sign-url; Transak uses backend-onlyPOST /onramp/sessionwith a trusted proxy-owned client IP. The browser must preserveRefererwhen it opens the returned opaque URL—never usenoreferrerorno-referrer. GET /onramp/pendingperforms bounded provider reads and returns only strictly admitted completed orders with an on-chain transaction hash.- The client submits the matching transfer-log amount to
/deposits/check; only the existing verifier → sweep → ROFL credit path changes balances.
Recovery is stateless. The selected provider supplies wallet bootstrap, while explicitly supplied signed intents dispatch exact recovery to their encoded provider. Transak Partner Access Tokens are cached in memory using returned expiresAt, refreshed single-flight, and retried once only after an explicit 401. Webhooks are verified, redacted observability signals and never create local order state or credit.
Key files: services/onramp_intent.py, services/onramp.py, services/transak.py, api/routes.py.
sweep_native gas-fund sweep tx mined
starts succeeds (receipt.status=1)
(no record) ──────▶ PENDING ────────▶ GAS_FUNDED ────────▶ SWEPT
│ │
│ │
├──▶ (native: skip GAS_FUNDED if ───────┤
│ deposit-addr balance covers │
│ gas + amount) │
│ ▼
│ creditDeposit
│ │
│ ▼
│ record deleted
│
└─ on failure: stay in current state, error stored,
recovery loop retries
Concurrency:
- One
asyncio.Lockper(deposit_address, chain_id)— concurrent claims for the same address queue. - One global lock around gas-tank nonce reads (prevents concurrent sweeps from reusing the same nonce on the gas tank).
Persistence: one JSON file per active sweep at /data/sweep-engine/sweep_<deposit_id>.json (matches SweepRecord dataclass; keyed by the unique deposit_id so same-address deposits never clobber each other). On startup, resume_incomplete_sweeps() migrates any legacy sweep_<address>_<chain_id>.json files to the deposit_id key and re-drives any PENDING / GAS_FUNDED records.
Recovery semantics:
SWEPTrecords → retrycreditDeposit(idempotent viaDepositAlreadyProcessedrevert).GAS_FUNDEDwith a mined sweep tx → promote toSWEPT, then credit.PENDING/GAS_FUNDEDwithout a sweep tx → re-run sweep from scratch (no nonce-collision risk because no tx was broadcast).GAS_FUNDEDwith an unmined / dropped / reverted sweep tx → logged for manual inspection (the gas-tank nonce may be encumbered).
A periodic recovery loop (SWEEP_RECOVERY_INTERVAL = 60s) retries any SWEPT records whose credit failed.
Key file: services/sweep_engine.py.
Two halves: user-driven request (HTTP), then ROFL-driven resolution (background poll loop).
Client FastAPI Sapphire Destination Chain
│ │ │ │
│── /withdraw (EIP-712) ─────▶│ │ │
│ │── requestWithdrawal ────▶│ │
│ │ (assigns nonce, emits │ │
│ │ Withdrawal event) │ │
│◀── tx_hash ────────────────│ │ │
WithdrawalProcessor (background, ~12s poll)
│ │
│── poll pending withdrawals (per chain, sequential by index)
│── wait 1-block delay │
│── resolveWithdrawal (tx) ─▶│ (marks resolved, emits WithdrawalResolved)
│── resolveWithdrawal.call() │ → returns Sapphire-signed RLP tx
│── eth_sendRawTransaction ─────────────────────────────────────────▶│
│ │ │ (broadcast)
WithdrawalProcessor also runs a periodic catch-up (_catch_up_missing_broadcasts) that compares destination-chain on-chain nonces against per-chain high-water marks, re-broadcasting any signed transactions that never landed.
Withdrawals are processed sequentially per chain to preserve nonce ordering. A failure on one chain does not block others.
Key file: services/withdrawal_processor.py.
At startup (main.py:99), after the AuthToken encryption key has been synced, bootstrap_rofl_signer_address runs:
- Derive the ROFL query-signer keypair via
RoflAppdClient.get_keypair(ROFL_QUERY_SIGNER_KEY). - Read
roflSignerAddressfrom the Accounting contract. - If they differ, submit
setRoflSignerAddress(...)(gated byonlyROFL).
Idempotent: subsequent starts no-op when the address is already in sync. The published address is what onlyROFLQuery view-call functions check msg.sender against (see solidity/README.md → Security Considerations).
Key file: services/rofl_signer_bootstrap.py.
At startup (main.py, right after the ROFL signer bootstrap), bootstrap_gas_prices runs:
- Read desired per-chain gas prices from the
ACCOUNTING_GAS_PRICEJSON env var (config._build_gas_prices). - For each configured chain, read
gasPrices(chainId)from the Accounting contract. - If they differ, submit
setGasPrice(chainId, gasPrice)viaRoflAppdClient(gated byonlyROFL— must be ROFL-signed, not a plain admin key).
Idempotent: chains already in sync are skipped. Per-chain, not all-or-nothing — a failure syncing one chain is logged and does not block the others or abort startup. Chains omitted from the ACCOUNTING_GAS_PRICE mapping are left untouched.
Key file: services/gas_price_bootstrap.py.
At startup (main.py, right after the ROFL signer bootstrap, before the gas price sync), bootstrap_token_info runs:
- Read the desired token list from the
ACCOUNTING_TOKEN_INFOJSON env var (config._build_token_infos) — each entry is{"chain_id": <int>}for a native token or{"chain_id": <int>, "token_address": "0x..."}for an ERC20 token. - For each entry, compute
(data, tokenId)via the contract'sencodeEVMNativeTokenData/encodeEVMErc20TokenData+getTokenIdhelpers, then checktokens(tokenId)on-chain. - If not yet registered, submit
setTokenInfo((tokenType, data))viaRoflAppdClient(gated byonlyROFL).
Idempotent: a tokenId is a hash of its type+data, so an already-registered tokenId implies the on-chain data already matches — no update path is needed. Per-token, not all-or-nothing — a failure registering one token is logged and does not block the others or abort startup.
Key file: services/token_info_bootstrap.py.
| What | Where | Lifecycle |
|---|---|---|
| Sweep records | /data/sweep-engine/sweep_<deposit_id>.json (one file per active sweep) |
Created on PENDING, atomically replaced on each transition, deleted after credit. Survives ROFL restarts. |
| JWT signing key | Derived in-memory from ROFL TEE seed at startup | Re-derived on each start; deterministic per ROFL app. |
| AuthToken encryption key | Derived in-memory from ROFL TEE seed; also synced to AccountingSiweAuth on Sapphire |
At first start, auth_token_keys.sync_key_to_contract() writes it on-chain so view-call SIWE token decryption works inside the contract. |
| On-ramp intent key ring | Derived in-memory from ROFL raw-256 key IDs at startup | Re-derived on each start; deterministic per ROFL app and key ID. |
| Withdrawal high-water marks | In-memory only (WithdrawalProcessor._chain_high_water_mark) |
Rebuilt on restart via the catch-up pass. |
| Transak Partner Access Token | In-memory only (TransakService) |
Refreshed from expiresAt; the current value authenticates API reads, while current and bounded previous values verify webhooks. |
Per-chain settings live in src/config/chain_config.py as a ChainConfig dataclass:
ChainConfig(
chain_id=84532,
finality_depth=15, # block confirmations required
min_deposit_native_wei=1_000_000_000_000_000, # 0.001 ETH
min_deposit_erc20_wei=1_000_000, # 1 USDC (6 decimals)
gas_funding_amount_wei=200_000_000_000_000, # ~65k gas * 3 gwei
l2_type=L2Type.OP_STACK, # for L1-data-fee estimation
)Adding a new chain is a single CHAIN_CONFIGS entry — no parallel dicts to keep in sync.
Environment variables: see .env.localnet, .env.testnet, and .env.localnet.secrets.example. Notable ones:
SAPPHIRE_RPC_URL,ALCHEMY_API_KEY— chain RPC accessACCOUNTING_CONTRACT_ADDRESS— the deployed proxyACCOUNTING_GAS_PRICE— JSON object mapping chain_id to desired gas price (wei), synced on-chain at startup (see Gas Price Bootstrap above)ACCOUNTING_TOKEN_INFO— JSON array of token descriptors to register on-chain at startup (see Token Info Bootstrap above)SIWE_DOMAINS— comma-separated allowed SIWE domainsONRAMP_INTENT_SIGNING_KEY_ID— current ROFL key ID for provider-neutral intent signingONRAMP_INTENT_PREVIOUS_SIGNING_KEY_IDS— comma-separated old IDs retained for intent verificationSAPPHIRE_PRIVATE_KEY(local dev only) — bypasses ROFL appd; uses a direct EOA for Sapphire txsDISABLE_ROFL_KEYS(local dev only) — use non-TEE AuthToken/JWT keys and publicly derivable on-ramp intent keys; never enable in productionSWEEP_STATE_DIR(default/data/sweep-engine) — sweep state directoryONRAMP_PROVIDER— required deployment-owned provider selection; shipped environments selectmoonpayTRANSAK_API_KEY,TRANSAK_API_SECRET— backend-only partner credentialsTRANSAK_API_BASE_URL,TRANSAK_GATEWAY_BASE_URL,TRANSAK_REFERRER_DOMAIN— approved Transak environment/domainTRANSAK_CLIENT_IP_HEADER— proxy-owned single client-IP header; leave unset until spoofing is preventedTRANSAK_CRYPTO_CURRENCY_CODE,TRANSAK_NETWORK,TRANSAK_CHAIN_ID,TRANSAK_TOKEN_ADDRESS— the one supported Transak asset
Invalid or incomplete Transak configuration disables its endpoints with 503 without blocking application startup. Do not switch ONRAMP_PROVIDER=transak until the asset is registered and the proxy/header, allowlist, credentials, and one-worker token-refresh topology are verified.
# All-in-one local stack (no ROFL needed):
docker compose -f compose.localnet.yaml up --buildcompose.localnet.yaml builds with .env.localnet and accepts secrets through the runtime environment. When running with SAPPHIRE_PRIVATE_KEY set, the ROFL appd code paths are bypassed — the FDC relayer / signer is the EOA derived from that key.
# Pre-requisite: compiled Solidity ABI artifacts
make solidity-build
# Full Python suite
uv run pytest test/py/ -v
# A single test
uv run pytest test/py/test_sweep_engine.py::test_sweep_native_happy_path -vMocking: services/sweep_engine.py defines DepositAccountingProtocol (a runtime-checkable Protocol) — tests can pass any object that conforms to its 10 methods in place of AccountingContractService.
Notable test files:
| File | Covers |
|---|---|
test_deposit_verifier.py |
RPC-side verification (status, finality, log matching, balance-delta fallback) |
test_deposit_processor.py |
Orchestration, idempotency, background-task lifecycle |
test_sweep_engine.py |
State machine, persistence, gas-tank concurrency |
test_sweep_recovery.py |
Restart recovery for each SweepState |
test_withdrawals.py |
Withdrawal poll/resolve/broadcast happy paths and catch-up |
test_rofl_signer_bootstrap.py |
Idempotent on-chain signer publication |
test_gas_price_bootstrap.py |
Idempotent per-chain gas price sync |
test_token_info_bootstrap.py |
Idempotent token registration |
test_accounting_contract_service.py |
Sapphire client (ROFL vs direct-key paths) |
test_transak.py |
Token lifecycle, sessions, bounded orders, strict admission, webhook verification |
test_transak_routes.py |
Provider selection, auth/ownership, registry binding, recovery, rate/error mapping |