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 fromMockTwapConsumer, which thetwap/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
closeOnChainwith no CRX in the loop; onprod, 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.
| Part | Repo location | Today | This plan turns it into |
|---|---|---|---|
| NDF contract | contracts/src/ | HelloWorld.sol | NDFFactory + NDF + PositionRegistry + GuaranteeFund — custody, mark, margin call, liquidation, settle, on-chain close |
| Mark keeper | twap/ (Rust/alloy) | built — polls Hermes, TWAPs, pushes MockTwapConsumer | repoint to USD/PHP + L3; later read by NDF |
| Margin/liquidation keeper | keeper/ (Node/viem) | interval block-poll stub | watch positions, fire marginCall, fire liquidate after the window |
| Mock-activity bot | bot/ (new, Rust) | absent | plays Taker and Maker; binds + settles on a loop to fill dev |
| Frontend | frontend/ | wallet-connect skeleton | RFQ form, position view, margin status, KYB placeholder |
| Secrets/env | envs/, scripts/ | .env.examples written | switch-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 | |
|---|---|---|
| Role | All development and testing, now | Demo / later |
| Chain | General Market L3 (Arbitrum Orbit) | Base Sepolia |
| Chain ID | 111222333 | 84532 |
| RPC | https://rpc.generalmarket.io | https://sepolia.base.org |
| Mark source | MockTwapConsumer, pushed by twap/ | real Pyth on-chain (or same keeper-push pattern) |
| Demo pair | USD/PHP (real Pyth feed via Hermes) | USD/PHP |
| Collateral | L3 WUSDC 0xaddB799… (18 dec) | Base Sepolia USDC (6 dec) or mock |
| Gas funding | reused funded L3 key (§5) | Base Sepolia faucet |
| Explorer | generalmarket.io explorer | sepolia.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
NDFFactorydeploys a freshNDFposition (factory-of-clones is the clean default) when a Taker binds.PositionRegistryindexes 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)
| Function | Who calls it | Behaviour | Invariant it protects |
|---|---|---|---|
bind | Taker (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 factory | Custody is trustless — funds enter only on two valid signatures; no operator required |
mark | Keeper (twap/) / anyone | Read the TWAP'd mark from the consumer (or Pyth); store it | Margin price ≠ settlement price |
marginCall | Keeper / anyone | When posted margin < maintenance, emit a margin call, open a fixed top-up window | A position cannot silently go underwater |
liquidate | Keeper (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 short | Loss stops at the defaulter, never the honest side |
settle | Keeper / anyone | At expiry, accept the EMTA fixing, validate against the last mark within tolerance, pay the winner, return remaining margin — atomically or revert | No partial-settlement state |
closeOnChain | Taker + Maker jointly | Close with no operator and no keeper in the loop | The failsafe — what makes custody actually trustless |
The two prices, in the contract
- Mark (margin): continuous, TWAP'd, from the consumer/Pyth. Decides when
marginCallfires. - 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.
- Initial margin (IM). Taker 2%, Maker 1% of notional, at bind. The first buffer.
- Maintenance margin (MM). A floor below IM. Cross it →
marginCall. - Margin call + top-up window. The losing side has a fixed window to restore margin. Top up → the position lives.
- Liquidation (keeper). Window lapses unanswered → the keeper calls
liquidate. The position closes at the current mark; the defaulter's remaining margin is seized. - 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:
closeOnChainneeds 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 callscloseOnChain. Ondev,closeOnChainaccepts 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/*.jsonwriteback —index/contracts/does exactly this. Match its deploy pattern: readPRIVATE_KEY/DEPLOYER_PRIVATE_KEYfrom env,vm.startBroadcast, write the address todeployments/<env>.json. The repo already hasDeployMockTwap.s.solin 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
publishTimeor the contract revertsStaleUpdate. 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
| Pair | On Pyth? | Feed id | Use |
|---|---|---|---|
| USD/PHP | yes | 0x2bda7f268b52bfbc3f2e124c31445247647350db313caadc6771e6299e0a68c9 | the demo pair — real launch pair, real feed |
| USD/INR | no | — | placeholder/EMTA path only, until a feed exists |
| EUR/USD | yes | 0xa995d00bb36a63cef7fd2c287dc105fc8f3d93779f062f09551b0af3e81ec30b | fallback major if needed |
| GBP/USD | yes | 0x84c2dde9633d93d1bcad84e7dc41c9d56578b7ec52fabedc1f335d673df0a7c1 | — |
| USD/JPY | yes | 0xef2c98c804ba503c6a707e38be4dfbb16683775f195b091252bf24693042fd52 | — |
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 MVPMockTwapConsumer.pushTwapis keeper-gated (setKeeper), because the keeper signs the computed TWAP itself. That's a deliberate MVP shortcut, not the production property. The failsafe ondevinstead letscloseOnChainaccept 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). Thetwap/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 / config | Used by | dev source | prod source |
|---|---|---|---|
| L3 RPC URL | twap, keeper, forge, frontend | https://rpc.generalmarket.io (index frontend/lib/config.ts) | n/a |
| Base Sepolia RPC | twap, keeper, forge, frontend | n/a | https://sepolia.base.org |
| Chain ID | everything | 111222333 (index switch-env.sh) | 84532 |
| Deployer key | forge (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 key | twap/ push, keeper/ liquidation — must be an authorized keeper (setKeeper) | same funded L3 key, or a derived one authorized on-chain | new key |
| Gas-funding key | top up keeper + bot fleet | issuer key ISSUER_2 0xC0d3ca67… (the L3 GM mother lode — index notes) | faucet |
| USDC address | contract, keeper, frontend | 0xaddB799BC1499b224DC4368e92b9042a54908553 (L3 WUSDC, 18 dec) — index deployment.json L3_WUSDC | Base Sepolia USDC (6 dec) or mock |
| TWAP consumer address | twap, contract, frontend | MockTwapConsumer from deployments/*.json after deploy | real Pyth 0xA2aa501b19aff244D90cc15a4Cf739D2725B5729 (verify) |
| Pyth feed id (USD/PHP) | twap, contract | 0x2bda7f268b52bfbc3f2e124c31445247647350db313caadc6771e6299e0a68c9 | same |
| Hermes endpoint | twap | https://hermes.pyth.network | same |
| TWAP windows | twap | WINDOW_SECS, POLL_INTERVAL_SECS, PUSH_INTERVAL_SECS (twap/.env) | same |
| EMTA fixing | settle | fixed test value | fixed test value (MVP) |
| Basescan key | forge verify | optional | BASESCAN_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.envdirectly in frontend API routes — index funnels everything throughfrontend/lib/config.ts/e2e/env.ts. One config module per workspace. switch-env.shvalidates chain ID before copying — refuse a deployment whosechainIddoesn'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.
| Screen | Content | Source of truth |
|---|---|---|
| RFQ form | pair, tenor, notional, direction; on quote-accept the Taker's wallet submits the bind tx | writes the position on-chain |
| Position view | current mark, margin posted, maintenance level, time to expiry | reads PositionRegistry + NDF via viem |
| Margin status | healthy / called / liquidatable, with the top-up countdown | reads contract events |
| KYB onboarding | UI-only placeholder | none (stub) |
Reuse from index
useWalletLoginpattern — every Connect/Login button goes through one canonical hook; bespokeconnect()silently no-ops on mobile (useWalletLogin-canonical). Build one login hook.- Chain config in one module —
frontend/lib/config.tsshape. - Keep the wagmi v3
tempo/accountsstub innext.config.ts(CLAUDE.md) — the connectors barrel breaks the browser build without it. - Deploy is the CRX path, not index's:
app.crxfx.comon VPS 3 viarsync+docker build+docker runbehind 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
binddirectly 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/, alongsidecontracts/andtwap/. Share thetwap/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-swarmpattern — 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
bindEIP-712 struct once; bot and contract must agree.
Reuse from index
vision-swarmfleet — funded bot wallets generating L3 volume. Sizing, funding cadence, "join clean + funded" discipline transfer directly.nsgame/oracle-daemon(Rust + EVM) andtwap/— 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).
- Env scaffolding (§5).
switch-env.sh,import-from-index.sh, real.envs fromindex. Verifyswitch-env.sh devlands workingtwap/.env+keeper/.env. No protocol code. - Repoint
twap/to USD/PHP on the L3. Already built — change the pair key, deployMockTwapConsumerto the L3, confirm a fresh mark reads back. (Mostly done.) - NDF core + factory/registry.
NDFFactorymints a position; bind → custody → mark (read the consumer) → settle →closeOnChain;PositionRegistryindexes it.forge testper path. The load-bearing work. - Margin call → liquidation → guarantee fund.
marginCall, top-up window, keeperliquidate, and theGuaranteeFundbackstop when seized margin falls short. - Margin/liquidation keeper (
keeper/). Watch the registry, firemarginCallandliquidateagainst the livedevcontract. - Mock-activity bot.
bot/plays both sides on a loop. Depends on bind + settle (phase 3). - Frontend. RFQ, position, margin, KYB placeholder — reads the positions the bot creates.
prodswap. 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= reuseindex E2E_VISION_PLAYER_KEY(pre-funded); fund the bot fleet fromISSUER_2 0xC0d3ca67…. Confirm withcast balance. (§5) - Draft build-agreement is not the engineering spec — Max is the truth (CLAUDE.md §Source of truth).
Open questions
- 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.
- EIP-712 struct for
bind. Define the signed-agreement payload once; bot and contract must match byte-for-byte. - Dev consumer: keeper-gated or permissionless? Keeping it gated diverges from prod's permissionless push. Mirror prod, or accept the MVP shortcut?
- Factory-of-clones vs singleton + registry record — decide at phase 3 (clones is the clean default).
Glossary
| Term | Meaning |
|---|---|
| NDF | Non-deliverable forward — locks a future FX rate, cash-settled on the difference at expiry |
| Mark / MTM | Continuous revaluation of the open position, TWAP'd from Pyth |
| TWAP | Time-weighted average price — smooths the mark against single-tick noise; lives in twap/ |
| Pyth / Hermes | Pull-oracle + its off-chain price service; real on Base Sepolia, fed off-chain into MockTwapConsumer on the L3 |
| MockTwapConsumer | The L3 placeholder the twap/ keeper pushes the mark to; the NDF reads it via getMark |
| EMTA | Emerging Markets Traders Association — the discrete bank fixing used for settlement |
| NDFFactory / PositionRegistry / GuaranteeFund | Deploys positions / indexes them / backstops gap-risk shortfall |
| closeOnChain | Taker + Maker jointly close a position with no CRX in the loop — the trustless failsafe |
| IM / MM | Initial margin (Taker 2%, Maker 1%) / maintenance margin (the floor that triggers a call) |
| L3 | General Market's Arbitrum Orbit chain, 111222333 — the dev target |