Infrastructure plan

Every moving part of the MVP — contracts, keeper, oracle, bot, frontend — and the two environments.

This is a plan, not a contract spec. It names every moving part for the MVP, fixes the two environments, records the decisions you've made, and lists exactly which secrets and patterns to reuse from the index repo. Most protocol code is not written yet — the exception is twap/, which already runs (the keeper's mark loop).

Spec of record: architecture.md, CRX-Whitepaper.md. Source of truth for engineering = this doc + Max's live instructions. The mvp-build-agreement.md draft is a commercial document, not the engineering spec — ignore its Base-Sepolia-only constraint (recorded in CLAUDE.md).


TL;DR

  • Two environments, one codebase, config swaps between them.
    • dev — our L3 (Arbitrum Orbit, 111222333). All tests run here, now. No Pyth contract on-chain → the mark comes from MockTwapConsumer, which the twap/ keeper pushes.
    • prod — Base Sepolia (84532). Real Pyth on-chain. Same contract interface; only the mark source changes.
  • Demo pair = USD/PHP. It's a real launch pair and a real Pyth feed (0x2bda7f…). USD/INR is not on Pyth → it stays on the placeholder/EMTA path until a feed exists.
  • Six parts: NDF contract (+ factory, registry, guarantee fund), twap/ mark keeper (built), margin/liquidation keeper, Rust mock-activity bot, frontend, and the secrets/env scaffolding.
  • The risk waterfall: IM → MM → margin call → keeper liquidation → guarantee fund. The guarantee fund is the layer that exists because IM + MM is not always enough — it covers a gap move between two keeper marks.
  • Trustless by construction: Taker + Maker can closeOnChain with no CRX in the loop; on prod, anyone can push a Pyth price (permissionless), so a CRX-down close still has a current mark.

Next step: point the existing twap/ crate at USD/PHP on the L3 (it currently keys USD/INR, which Pyth doesn't carry — see §4), then build the NDF contract (§8).


1. The shape of the system

Six parts. One (twap/) already runs; the rest are stubs or absent.

PartRepo locationTodayThis plan turns it into
NDF contractcontracts/src/HelloWorld.solNDFFactory + NDF + PositionRegistry + GuaranteeFund — custody, mark, margin call, liquidation, settle, on-chain close
Mark keepertwap/ (Rust/alloy)built — polls Hermes, TWAPs, pushes MockTwapConsumerrepoint to USD/PHP + L3; later read by NDF
Margin/liquidation keeperkeeper/ (Node/viem)interval block-poll stubwatch positions, fire marginCall, fire liquidate after the window
Mock-activity botbot/ (new, Rust)absentplays Taker and Maker; binds + settles on a loop to fill dev
Frontendfrontend/wallet-connect skeletonRFQ form, position view, margin status, KYB placeholder
Secrets/envenvs/, scripts/.env.examples writtenswitch-env.sh + import-from-index.sh + gitignored .envs

Three off-chain processes: twap/ pushes the mark, keeper/ watches margin and liquidates, bot/ creates positions. None of them binds on a counterparty's behalf — the Taker submits bind when accepting a quote (from the bot or the frontend). The full RFQ matcher is out of MVP scope.

One sentence holds the whole design together: the price that decides a margin call and the price that decides the payout are never the same price.


2. Environments

dev (our testnet)prod
RoleAll development and testing, nowDemo / later
ChainGeneral Market L3 (Arbitrum Orbit)Base Sepolia
Chain ID11122233384532
RPChttps://rpc.generalmarket.iohttps://sepolia.base.org
Mark sourceMockTwapConsumer, pushed by twap/real Pyth on-chain (or same keeper-push pattern)
Demo pairUSD/PHP (real Pyth feed via Hermes)USD/PHP
CollateralL3 WUSDC 0xaddB799… (18 dec)Base Sepolia USDC (6 dec) or mock
Gas fundingreused funded L3 key (§5)Base Sepolia faucet
Explorergeneralmarket.io explorersepolia.basescan.org

Why this split. Our L3 is the chain we control and already run — fastest to iterate, no faucet friction — but Pyth is not deployed there. So on dev the NDF reads a mark the twap/ keeper computes off-chain (from real Pyth/Hermes prices) and pushes to MockTwapConsumer. On prod, the same mark can come straight from the on-chain Pyth contract. Hermes is chain-agnostic, so USD/PHP real prices flow on both envs — only the on-chain sink differs.

Therefore: the NDF reads the mark through one small interface (getMark), never from a hardcoded feed. The placeholder consumer and real Pyth must be interchangeable behind it.


3. NDF contract

The load-bearing component. One bilateral NDF between a Taker and a Maker.

Architecture: factory, registry, guarantee fund

  • NDFFactory deploys a fresh NDF position (factory-of-clones is the clean default) when a Taker binds.
  • PositionRegistry indexes every position by Taker, Maker, pair, and state. The frontend's position list and the keeper's watch loop both read it — one source of truth for "what positions exist."
  • GuaranteeFund — a shared pool the protocol draws on when a liquidation can't make the honest side whole (the risk waterfall, below).

What it must do (MVP)

FunctionWho calls itBehaviourInvariant it protects
bindTaker (or anyone holding both signatures)Validate both EIP-712 signatures; pull IM (Taker 2%, Maker 1% of notional) in USDC from both sides; open the position via the factoryCustody is trustless — funds enter only on two valid signatures; no operator required
markKeeper (twap/) / anyoneRead the TWAP'd mark from the consumer (or Pyth); store itMargin price ≠ settlement price
marginCallKeeper / anyoneWhen posted margin < maintenance, emit a margin call, open a fixed top-up windowA position cannot silently go underwater
liquidateKeeper (or anyone)After the top-up window lapses unanswered, close at the current mark, seize the defaulter's margin, make the honest side whole — drawing on the guarantee fund if margin fell shortLoss stops at the defaulter, never the honest side
settleKeeper / anyoneAt expiry, accept the EMTA fixing, validate against the last mark within tolerance, pay the winner, return remaining margin — atomically or revertNo partial-settlement state
closeOnChainTaker + Maker jointlyClose with no operator and no keeper in the loopThe failsafe — what makes custody actually trustless

The two prices, in the contract

  • Mark (margin): continuous, TWAP'd, from the consumer/Pyth. Decides when marginCall fires.
  • Settlement (payout): the EMTA fixing, submitted once at expiry, accepted only if within tolerance of the last mark.

Different storage variables, different setters, different sources. Never substituted for one another. This is the single most dangerous error in the system; the type system should make conflating them hard.

The risk waterfall

The order in which money absorbs a loss. Each layer engages only when the one above it is exhausted.

  1. Initial margin (IM). Taker 2%, Maker 1% of notional, at bind. The first buffer.
  2. Maintenance margin (MM). A floor below IM. Cross it → marginCall.
  3. Margin call + top-up window. The losing side has a fixed window to restore margin. Top up → the position lives.
  4. Liquidation (keeper). Window lapses unanswered → the keeper calls liquidate. The position closes at the current mark; the defaulter's remaining margin is seized.
  5. Guarantee fund. If a gap move blew through IM and MM before the keeper could liquidate — the seized margin doesn't cover what the honest side is owed — the shortfall is paid from the GuaranteeFund. This is the layer that exists because IM + MM is not always enough.

Therefore: size the guarantee fund against gap risk, not normal moves — the worst single jump between two keeper marks, times the worst-case open notional.

For the MVP the guarantee fund is a funded-stub: a real pool the contract draws from, seeded by the operator, with the production funding mechanism (a cut of fees, or an insurance premium) left as a clean seam. Sizing and funding source are open questions below.

Failsafe: close on-chain without CRX

The system must survive CRX going dark. With twap/, keeper/, bot/, and frontend all offline, Taker and Maker can still settle:

  • closeOnChain needs both signatures, nothing else. No operator wallet, no keeper, no CRX mark push. The two counterparties agree a close; the contract pays out from stored margin.
  • Price for a CRX-down close. On prod, either party pushes a fresh Pyth update themselves (pushes are permissionless — §4) so the mark is current, then calls closeOnChain. On dev, closeOnChain accepts a counterparty-supplied mark since the placeholder consumer is keeper-gated.
  • This is an invariant, not a feature. Every function added later preserves it. A position that can only be closed through CRX is a custody failure.

Reuse from index

  • Foundry layout, remappings, deploy-script shape, deployments/*.json writebackindex/contracts/ does exactly this. Match its deploy pattern: read PRIVATE_KEY/DEPLOYER_PRIVATE_KEY from env, vm.startBroadcast, write the address to deployments/<env>.json. The repo already has DeployMockTwap.s.sol in this shape — follow it.
  • CREATE2 / split-deploy discipline — index hit address divergence between forge simulation and broadcast on the Orbit L3 (testnet-deploy-orbit-issues). Expect it here. Use CREATE2 or split deploys for any cross-referencing addresses (factory ↔ registry ↔ fund).
  • Decimals are a trap. L3 WUSDC is 18 decimals, Base USDC is 6, Pyth marks are expo = -8. Carry every decimal in config; never hardcode.

Build target: NDFFactory + NDF + PositionRegistry + GuaranteeFund replace HelloWorld.sol. forge test covers bind, custody, margin call, liquidation, guarantee-fund draw, settle, and closeOnChain.


4. The mark: Pyth → TWAP

The mark is already built in twap/ (Rust/alloy). It polls Pyth Hermes for real prices, integrates a rolling time-weighted average over a window, and pushes the result on-chain to MockTwapConsumer. See twap/README.md.

How it works today

Pyth Hermes (real prices) ──poll──▶ rolling TWAP buffer ──push──▶ MockTwapConsumer.pushTwap(...) on L3
  • Keeper computes the mark; it does not forward Pyth's blob. On-chain Pyth-blob verification belongs to the settlement path, not this MVP.
  • TWAP smooths it. A single noisy tick is weighted only by the seconds it was live — it can't trigger a spurious liquidation.
  • Monotonic. Each push must carry a newer publishTime or the contract reverts StaleUpdate. When FX is closed (weekend) the feed stops and pushes no-op.
  • Fixed point. Mark stored as price * 10^expo, expo = -8.

Which FX pair — resolved

PairOn Pyth?Feed idUse
USD/PHPyes0x2bda7f268b52bfbc3f2e124c31445247647350db313caadc6771e6299e0a68c9the demo pair — real launch pair, real feed
USD/INRnoplaceholder/EMTA path only, until a feed exists
EUR/USDyes0xa995d00bb36a63cef7fd2c287dc105fc8f3d93779f062f09551b0af3e81ec30bfallback major if needed
GBP/USDyes0x84c2dde9633d93d1bcad84e7dc41c9d56578b7ec52fabedc1f335d673df0a7c1
USD/JPYyes0xef2c98c804ba503c6a707e38be4dfbb16683775f195b091252bf24693042fd52

Action: the twap/ README/example currently keys USD/INR, which Pyth does not publish — switch it to USD/PHP so it pulls real prices. Confirm any id against the live Hermes FX list (https://hermes.pyth.network/v2/price_feeds?asset_type=fx).

Anyone can push the price

Pyth's pull model is permissionless by design: any address can call updatePriceFeeds with a Hermes-signed update and pay the fee. CRX does not gate it.

  • prod: the keeper pushes on a schedule, but a Taker or Maker can push too — required for the CRX-down close (§3 failsafe).
  • dev: the MVP MockTwapConsumer.pushTwap is keeper-gated (setKeeper), because the keeper signs the computed TWAP itself. That's a deliberate MVP shortcut, not the production property. The failsafe on dev instead lets closeOnChain accept a counterparty-supplied mark. (Open question: do we want the dev consumer permissionless too, to mirror prod exactly?)
  • Implication on prod: never assume the last pusher was the keeper. The contract trusts the Pyth update's own signature and staleness check, not the caller.

EMTA settlement feed

Settlement is a separate feed from the mark. For the MVP the EMTA fixing is a fixed test value or a keeper-submitted value — not the two-independent-submitter production design. The contract validates it against the last mark within tolerance. The two-oracle production design is out of MVP scope; leave a clean seam for it.

Reuse from index

  • Funded-key discipline. Index's oracle leader wallets drain fastest where they push most (oracle-leader-wallets-drain). The twap/ push key spends L3 gas every push — fund it from an issuer key and watch the balance.
  • Pattern only, no code. Index's oracle is a custom Rust fleet, not Pyth — the price-source code doesn't transfer, only the daemon-with-funded-key shape.

Build target (mostly done): twap/ keyed to USD/PHP, pushing to MockTwapConsumer on the L3. Acceptance: read a fresh mark back with cast call …getMark. Next: the NDF reads the same consumer.


5. Secrets & config inventory

Handled exactly like index: per-environment .env + gitignored keys/, swapped by switch-env.sh. Nothing secret is committed. The repo's .gitignore already ignores *.env, .env, and deployments/*.json — that covers us.

Layout

crx-mono/
  envs/dev/.env.example       # committed — placeholders + pointers   (written)
  envs/dev/.env               # gitignored — real values
  envs/prod/.env.example      # committed                            (written)
  envs/prod/.env              # gitignored
  envs/<env>/keys/            # gitignored — key files
  scripts/switch-env.sh       # copies envs/<env>/.env → keeper/.env, twap/.env, frontend/.env.local
  scripts/import-from-index.sh# copies reusable L3 secrets from ../index
  twap/.env                   # gitignored — twap service config (CONSUMER_ADDR, keeper key, windows)

What each secret is, and where it comes from

Secret / configUsed bydev sourceprod source
L3 RPC URLtwap, keeper, forge, frontendhttps://rpc.generalmarket.io (index frontend/lib/config.ts)n/a
Base Sepolia RPCtwap, keeper, forge, frontendn/ahttps://sepolia.base.org
Chain IDeverything111222333 (index switch-env.sh)84532
Deployer keyforge (PRIVATE_KEY / DEPLOYER_PRIVATE_KEY)reuse index/envs/testnet/.env → E2E_VISION_PLAYER_KEY (pre-funded L3 gas + USDC)new Base Sepolia key + faucet
Keeper/operator keytwap/ push, keeper/ liquidation — must be an authorized keeper (setKeeper)same funded L3 key, or a derived one authorized on-chainnew key
Gas-funding keytop up keeper + bot fleetissuer key ISSUER_2 0xC0d3ca67… (the L3 GM mother lode — index notes)faucet
USDC addresscontract, keeper, frontend0xaddB799BC1499b224DC4368e92b9042a54908553 (L3 WUSDC, 18 dec) — index deployment.json L3_WUSDCBase Sepolia USDC (6 dec) or mock
TWAP consumer addresstwap, contract, frontendMockTwapConsumer from deployments/*.json after deployreal Pyth 0xA2aa501b19aff244D90cc15a4Cf739D2725B5729 (verify)
Pyth feed id (USD/PHP)twap, contract0x2bda7f268b52bfbc3f2e124c31445247647350db313caadc6771e6299e0a68c9same
Hermes endpointtwaphttps://hermes.pyth.networksame
TWAP windowstwapWINDOW_SECS, POLL_INTERVAL_SECS, PUSH_INTERVAL_SECS (twap/.env)same
EMTA fixingsettlefixed test valuefixed test value (MVP)
Basescan keyforge verifyoptionalBASESCAN_API_KEY — add a base entry to foundry.toml [etherscan] next to arbitrum

index key-handling gotchas (carry them over)

  • Write key files with printf "%s", not echo through quotes — variable expansion through SSH breaks with single quotes.
  • Never read process.env directly in frontend API routes — index funnels everything through frontend/lib/config.ts / e2e/env.ts. One config module per workspace.
  • switch-env.sh validates chain ID before copying — refuse a deployment whose chainId doesn't match the target env (dev = 111222333, prod = 84532). Stops Anvil addresses reaching a live chain.

Build target: switch-env.sh + import-from-index.sh created; real .envs populated from index, gitignored. Confirm the keeper key with cast balance before a run.


6. Frontend

Skeleton today (wallet-connect via wagmi/viem). The MVP adds three screens plus the KYB stub.

ScreenContentSource of truth
RFQ formpair, tenor, notional, direction; on quote-accept the Taker's wallet submits the bind txwrites the position on-chain
Position viewcurrent mark, margin posted, maintenance level, time to expiryreads PositionRegistry + NDF via viem
Margin statushealthy / called / liquidatable, with the top-up countdownreads contract events
KYB onboardingUI-only placeholdernone (stub)

Reuse from index

  • useWalletLogin pattern — every Connect/Login button goes through one canonical hook; bespoke connect() silently no-ops on mobile (useWalletLogin-canonical). Build one login hook.
  • Chain config in one modulefrontend/lib/config.ts shape.
  • Keep the wagmi v3 tempo/accounts stub in next.config.ts (CLAUDE.md) — the connectors barrel breaks the browser build without it.
  • Deploy is the CRX path, not index's: app.crxfx.com on VPS 3 via rsync + docker build + docker run behind nginx (CLAUDE.md §Deployment). Manual rsync — no auto-deploy.

Build target: three screens reading/writing the live dev contract; wallet login through one hook.


7. Mock-activity bot (Rust)

A standalone Rust crate that plays both sides of the book and keeps dev full of live positions, so the contract, keepers, and frontend always have real data to act on. The demo's heartbeat.

What it does

  • Buy side (Taker). Generates an RFQ, signs the Taker agreement, submits bind directly on accepting the quote (no operator, no keeper).
  • Sell side (Maker). Produces a firm signed quote and signs the Maker agreement.
  • Loop. Open positions with randomised notional/tenor/direction; let some run to settlement; occasionally drive one toward a margin call to exercise liquidation + the guarantee fund. Result: a steady population of OPEN / CALLED / LIQUIDATED / SETTLED positions.

Shape (mirror index's Rust services, no code copied)

  • Standalone Cargo crate bot/, alongside contracts/ and twap/. Share the twap/ EVM stack (alloy) so one toolchain spans both.
  • Multi-wallet, funded from one key. Derive N Taker + N Maker wallets, fund from the reused L3 key (§5). This is index's vision-swarm pattern — watch the funding balance, it starved when thin (vision-swarm-undersized).
  • Config-driven, env-switched. Reads envs/dev/.env. No hardcoded chain values.
  • Signing matches the contract byte-for-byte. Define the bind EIP-712 struct once; bot and contract must agree.

Reuse from index

  • vision-swarm fleet — funded bot wallets generating L3 volume. Sizing, funding cadence, "join clean + funded" discipline transfer directly.
  • nsgame/oracle-daemon (Rust + EVM) and twap/ — references for a Rust process signing and submitting EVM txs on a loop.

Build target: bot/ runs against dev, opens and settles positions on a loop; the frontend shows them live cycling through states on the L3 explorer.


8. Build order

Each phase ends green before the next (CLAUDE.md phased-execution rule).

  1. Env scaffolding (§5). switch-env.sh, import-from-index.sh, real .envs from index. Verify switch-env.sh dev lands working twap/.env + keeper/.env. No protocol code.
  2. Repoint twap/ to USD/PHP on the L3. Already built — change the pair key, deploy MockTwapConsumer to the L3, confirm a fresh mark reads back. (Mostly done.)
  3. NDF core + factory/registry. NDFFactory mints a position; bind → custody → mark (read the consumer) → settle → closeOnChain; PositionRegistry indexes it. forge test per path. The load-bearing work.
  4. Margin call → liquidation → guarantee fund. marginCall, top-up window, keeper liquidate, and the GuaranteeFund backstop when seized margin falls short.
  5. Margin/liquidation keeper (keeper/). Watch the registry, fire marginCall and liquidate against the live dev contract.
  6. Mock-activity bot. bot/ plays both sides on a loop. Depends on bind + settle (phase 3).
  7. Frontend. RFQ, position, margin, KYB placeholder — reads the positions the bot creates.
  8. prod swap. Point env at Base Sepolia + real Pyth. Confirm the same code runs.

Decisions (resolved)

  • Demo pair = USD/PHP — real launch pair, real Pyth feed. USD/INR not on Pyth → placeholder/EMTA. (§4)
  • Factory + registry, not a bespoke singleton. (§3)
  • The Taker binds on accepting a quote (bot or frontend). No operator, no keeper. (§3, §6, §7)
  • Keeper does liquidation, not binding. (§3, §5)
  • Price push is permissionless on prod (real Pyth); the dev consumer is keeper-gated as an MVP shortcut. (§4)
  • Operator/deployer key on dev = reuse index E2E_VISION_PLAYER_KEY (pre-funded); fund the bot fleet from ISSUER_2 0xC0d3ca67…. Confirm with cast balance. (§5)
  • Draft build-agreement is not the engineering spec — Max is the truth (CLAUDE.md §Source of truth).

Open questions

  1. Guarantee fund — size and funding source. Size against gap risk (worst inter-mark jump × worst open notional). MVP seeds it manually; production funds from fees or a premium — pick the mechanism.
  2. EIP-712 struct for bind. Define the signed-agreement payload once; bot and contract must match byte-for-byte.
  3. Dev consumer: keeper-gated or permissionless? Keeping it gated diverges from prod's permissionless push. Mirror prod, or accept the MVP shortcut?
  4. Factory-of-clones vs singleton + registry record — decide at phase 3 (clones is the clean default).

Glossary

TermMeaning
NDFNon-deliverable forward — locks a future FX rate, cash-settled on the difference at expiry
Mark / MTMContinuous revaluation of the open position, TWAP'd from Pyth
TWAPTime-weighted average price — smooths the mark against single-tick noise; lives in twap/
Pyth / HermesPull-oracle + its off-chain price service; real on Base Sepolia, fed off-chain into MockTwapConsumer on the L3
MockTwapConsumerThe L3 placeholder the twap/ keeper pushes the mark to; the NDF reads it via getMark
EMTAEmerging Markets Traders Association — the discrete bank fixing used for settlement
NDFFactory / PositionRegistry / GuaranteeFundDeploys positions / indexes them / backstops gap-risk shortfall
closeOnChainTaker + Maker jointly close a position with no CRX in the loop — the trustless failsafe
IM / MMInitial margin (Taker 2%, Maker 1%) / maintenance margin (the floor that triggers a call)
L3General Market's Arbitrum Orbit chain, 111222333 — the dev target