# NASDAFUQ testnet reward router: architecture decision record

Status: active for the testnet-only prototype. Date: 2026-09-16.
Authority: `research/nasdafuq-claude-build-handoff.md` (locked v1 decisions). This ADR refines it into concrete modules; it does not reopen settled decisions.

Everything here is testnet-only and mock-only. Nothing in this repository is legal clearance, a production deployment, a live token, or a promise of real Stock Token rewards. Mock reward tokens (`mDDOG`, `mAPLD`, `mFLY`, `mUSAR`, `mQUBT`) have no monetary value and are not Robinhood Stock Tokens.

## 1. Repository boundaries

| Area | Path | Runtime | Secrets | Authority |
| --- | --- | --- | --- | --- |
| Static site | `index.html`, `script.js`, `styles.css`, `assets/`, `data/` | browser only | none | none (display only) |
| Contracts | `contracts/` (Foundry) | EVM | none in repo; deployer key via env at deploy time only | onchain rules |
| Indexer + keeper tools | `indexer/` (Node, viem) | operator machine | keeper key via env at run time only | none for rules; reproducible computation |
| Deployment manifests | `deployments/` | public JSON | none | addresses/parameters record |
| Research | `research/` | docs | none | evidence only |

The site never imports from `contracts/` or `indexer/`. The only site data input is the static JSON file `data/tracker.json` written by the indexer. No wallet connection, no admin panel, no login.

## 2. Contract modules (contracts/src)

```
MockFeeMarket ──depositFees──▶ FeeVault ◀──activeBasket()── FixedBasketPolicy
                                  │ ▲
           AssetRegistry ◀────────┘ │ isKeeper / paused / delayed actions
           (asset→adapter)          │
                                    ├──▶ SafetyController (admin, pause flags, timelocked adapter rotation)
                                    ├──▶ KeeperRegistry (keeper set)
                                    ├──▶ HolderSnapshot (per-epoch root, block, totalEligible, datasetHash)
           MockRouteAdapter ◀── buy(tokenIn, tokenOut, amountIn, minOut, deadline)
                                    │
           PushDistributor ── pushBatch(epoch, leaves, proofs) ──▶ FeeVault.pushReward (custody + paid ledger)
```

| Module | Responsibility | Not allowed to |
| --- | --- | --- |
| `MockERC20` | Plain 18-decimal mintable ERC-20 used for the testnet NASDAFUQ token, the mock quote token (`mUSDG`), and the five mock reward assets. Has a test-only `setTransferBlocked(addr,bool)` so a reward token can reject a destination, modelling a transfer-restricted asset. | – |
| `MockFeeMarket` | Simulates trades: pulls quote from the trader, keeps `feeBps`, forwards the fee to `FeeVault.depositFees`. Registered as a fee source. | choose the vault after construction |
| `AssetRegistry` | Admin registers `{asset, symbol, decimals, adapter, status}`; status ∈ Active / Paused / Unavailable. Vault purchases only Active assets via their registered adapter. | be edited by keepers, site, or distributor |
| `FixedBasketPolicy` | Immutable five assets + weights (2000 bps each, sum 10000, unique, all registered at construction). `basketVersion = keccak256(abi.encode(assets, weights))`. Emits `BasketVersion` once at construction. | change after deployment |
| `IRouteAdapter` / `MockRouteAdapter` | Typed purchase seam. Mock has per-asset rate, liquidity cap, availability flag, simulated price impact. Enforces deadline, minOut, liquidity, availability; only callable by the vault. Failure = revert. | accept calldata, choose tokenOut, choose recipient |
| `HolderSnapshot` | Keeper commits once per epoch: `(epoch, snapshotBlock, merkleRoot, totalEligible, datasetHash)`. Immutable after commit. | be overwritten |
| `FeeVault` | Custody + accounting + epoch state machine + purchase execution + payout ledger. See §3. | arbitrary withdrawal; transfer earmarked inventory anywhere but to a proven snapshot wallet or the documented rollover bucket |
| `PushDistributor` | Verifies Merkle proofs against `HolderSnapshot`, computes pro-rata amounts, calls `FeeVault.pushReward` in bounded batches (`maxBatchSize`), bounded retries (`maxRetries`). | compute amounts from anything other than snapshot + vault state |
| `KeeperRegistry` | Admin-managed keeper set. Keepers trigger close/purchase/push. | alter basket, fees, recipients, amounts |
| `SafetyController` | Admin address (multisig in production). Pause flags for purchases and pushes. Timelocked adapter rotation. Explicit recovery of unsupported tokens only. | move earmarked reward inventory to an arbitrary address |

Design rules: checks-effects-interactions everywhere; OpenZeppelin `SafeERC20`, `MerkleProof`, `ReentrancyGuard`; token transfers to holders are done with a low-level safe call wrapped so a failing/nonstandard transfer is recorded, never bubbled. No `delegatecall`, no arbitrary `call(bytes)`.

## 3. Accounting model (FeeVault) — revision A (after threat-model critique, see §7)

All amounts are per epoch `e` unless noted. Quote token = mock quote. Assets = registered reward tokens. Immutable parameters: `keeperReserveBps` (≤ 2000), `maxSlippageBps`, `maxOracleDeviationBps`, `maxOracleStaleness`, `purchaseDeadline`, `minEpochDuration`, `minPayout`, `keeperTreasury`.

| Quantity | Meaning | Transition |
| --- | --- | --- |
| `grossFees[e]` | quote deposited by registered fee sources while `e` open | `depositFees` → `FeesDeposited(e, source, amount)` |
| `carryPending` | storage: quote waiting to be carried into the next epoch (unpurchasable allocations + allocation dust) | written by `purchase` failure path and `closeEpoch` dust; atomically moved into `carriedIn[e]` and zeroed by `closeEpoch(e)` |
| `carriedIn[e]` | quote rolled in from earlier epochs | `closeEpoch` |
| `keeperReserve[e]` | `grossFees * keeperReserveBps / 10000`, added to `keeperReserveBalance` | `closeEpoch` |
| `netBudget[e]` | `grossFees + carriedIn - keeperReserve` | `closeEpoch` |
| `allocated[e][a]` | `netBudget * weight(a) / 10000`; remainder dust → `carryPending` | `closeEpoch` → `Allocated(e,a,amount)` ×5, `EpochClosed(e, closeBlock, gross, carriedIn, reserve, net, basketVersion)` |
| `closeBlock[e]` | `block.number` at close; the snapshot MUST be taken at exactly this block | `closeEpoch` |
| `resolved[e][a]` | once-only guard: each `(e,a)` allocation is either purchased or rolled over exactly once | `purchase` |
| `spent[e][a]` / `expectedOut[e][a]` / `purchased[e][a]` | quote in (measured by vault balance delta, must equal amountIn), oracle-referenced expected out, actual reward out (balance delta). Slippage = expected − purchased | `purchase` success → `Purchased(e,a,spent,expectedOut,purchased)` |
| `rolledOverQuote[e][a]` | allocation that could not be purchased; added to `carryPending` | `purchase` failure → `AllocationRolledOver(e,a,amount,reason)` |
| `resolvedCount[e]` | number of resolved assets; `finalizePurchases` requires 5 | `purchase` |
| `inheritedInventory[a]` | reward-asset amount released by rolled-over failed pushes and below-minimum payouts; folded into the next epoch's `distributable[e][a]` and zeroed in the same tx | `rolloverFailedPush`; `finalizePurchases` → `InventoryInherited(e,a,amount)` |
| `distributable[e][a]` | `purchased[e][a] + inheritedInventory[a]` at `finalizePurchases` | `finalizePurchases(e)` → `PurchasesFinalized(e)` |
| `pushed[e][a]` | sum of successful pushes. HARD RULE: `require(pushed[e][a] + amount <= distributable[e][a])` | `pushReward` |
| `released[e][a]` | amount of epoch `e`'s inventory moved out to `inheritedInventory` (rollover + below-minimum) | `rolloverFailedPush`, `pushReward` (below-minimum path) |
| `paid[e][wallet][a]` | amount paid (0 = unpaid). Set only on transfer success → a second push for the same `(e,wallet,a)` is a no-op emitting `RewardAlreadyPaid` | `pushReward` → `RewardPushed(e,a,wallet,amount,attempt)` |
| `failedAmount[e][wallet][a]` / `attempts[e][wallet][a]` | last failed amount and attempt count. Retry allowed while `attempts < maxRetries` and not paid; `attempts` increments on every failed attempt | `pushReward` failure → `RewardPushFailed(e,a,wallet,amount,attempt,reason)` |
| `proven[e][wallet]` / `provenBalanceSum[e]` | first successful proof for a wallet adds its balance once; HARD RULE: `require(provenBalanceSum[e] <= totalEligible[e])` binds the committed total to the tree | `PushDistributor` |
| `keeperReserveBalance` | quote withdrawable only to the immutable `keeperTreasury` | `withdrawKeeperReserve` (admin) → `KeeperReserveWithdrawn` |

Per-wallet amount: `distributable[e][a] * balance / totalEligible[e]` (floor). If it is `< minPayout` the amount is released to `inheritedInventory[a]` (`RewardBelowMinimum(e,a,wallet,amount)`) and `paid` is set to 0 with a `belowMinimum` flag so it is never retried.

Purchase protection (all vault-side; adapter checks are belt-and-braces): `expectedOut = amountIn * oracle.rateWad(a) / 1e18`; require oracle not stale; require `|adapter.quote − expectedOut| <= maxOracleDeviationBps` of expectedOut; require `adapter.availableLiquidity(a) >= expectedOut`; `minOut = expectedOut * (10000 − maxSlippageBps) / 10000`; `forceApprove(adapter, amountIn)` → `buy(...)` → `forceApprove(adapter, 0)`; require quote delta == amountIn and reward delta >= minOut. Any failure (including asset not Active, purchases paused, adapter revert) takes the rollover path; the vault never reverts the epoch. Adapter must equal `registry.adapterOf(a)` at call time.

Epoch state machine: `Open → Closed → Purchasing → Distributing`. Epoch `e+1` opens when `e` closes. There is no finalization gate and no time-based sweep: an unpushed share stays pushable forever by anyone with a valid proof; only a failed push with `attempts >= maxRetries` can be rolled over by admin, and only into `inheritedInventory[a]`.

Invariants (checked in Foundry invariant tests):
1. `quote.balanceOf(vault) >= grossFees[current] + keeperReserveBalance + carryPending + Σ_e Σ_a (allocated − spent − rolledOverQuote)`.
2. For each asset `a`: `a.balanceOf(vault) >= Σ_e (distributable − pushed − released) + inheritedInventory[a]`.
3. `Σ_wallets paid[e][wallet][a] == pushed[e][a] <= distributable[e][a]`.
4. `paid[e][w][a]` is written at most once and never exceeds the pro-rata amount; `provenBalanceSum[e] <= totalEligible[e]`.

"100% to the community" definition used by contracts and site: 100% of **net distributable** fees, i.e. gross minus the published, immutable keeper/gas reserve (≤ 20%) and minus execution loss (slippage). Both are emitted.

## 4. Authority map — revision A

| Action | Who | Enforced where | Notes |
| --- | --- | --- | --- |
| Deposit fees | registered fee source | FeeVault | testnet: MockFeeMarket; sources set once by admin, append-only |
| Close epoch, purchase, finalizePurchases | keeper | FeeVault + KeeperRegistry | keeper cannot choose asset outside basket, adapter outside registry, recipient, or amount |
| Commit snapshot | keeper | HolderSnapshot | once per epoch; `snapshotBlock == vault.closeBlock[e]`; root/dataset hash public via `SnapshotCommitted`; total bound by `provenBalanceSum` rule |
| Push batch / retry | **anyone** with valid proofs (normally the keeper) | PushDistributor + FeeVault | recipient must be a proven snapshot leaf; amount computed onchain; batch ≤ `maxBatchSize`; transfer uses a gas-capped, returndata-bounded call so one bad token cannot kill a batch |
| Pause purchases/pushes | admin | SafetyController | immediate; `Paused(kind)` / `Unpaused(kind)` |
| Set asset status | admin | AssetRegistry | immediate; registration append-only (`AssetRegistered`, `AssetStatusSet`) |
| Rotate adapter | admin | SafetyController timelock (`adapterRotationDelay`) then AssetRegistry | `AdapterRotationQueued` / `AdapterRotationExecuted`; old adapter allowance is always zero outside a purchase |
| Rollover failed push | admin | FeeVault | only when `attempts[e][w][a] >= maxRetries`; amount → `inheritedInventory[a]`; `FailedPushRolledOver` |
| Withdraw keeper reserve | admin | FeeVault | only to immutable `keeperTreasury`, only up to reserve balance |
| Recover token | admin | FeeVault | only tokens never registered and not the quote (`everRegistered` is append-only); `TokenRecovered` |
| Set reward-asset price rate / mock route | **feed operator** (`FEED_OPERATOR_ADDRESS`, default admin; a retained key) | MockPriceOracle / MockRouteAdapter | Anchors `minOut` and the deviation check for every purchase. Cannot pay an unproven wallet or move inventory, but a dishonest or stale rate can spend an epoch's allocation for dust (a rate of 1 wei buys 17 wei for 18e18 quote — measured by the whole-product reviewer). This is the most economically powerful role in the prototype. A real, independently liveness-checked feed is production gate G2. |
| Set clip parameters (`maxClipQuote`, `minClipInterval`) | admin | AssetRegistry | immediate, bounded: interval ≤ 1 day; the amount cap only makes purchases smaller. `ClipParamsSet` |
| Basket / weights / reserve bps | nobody | immutable | new basket = new policy + new vault (future). The basket an epoch uses is frozen when that epoch opens |
| Website | nobody | – | read-only JSON |

Off-chain computation that is *not* contract-enforced: the holder balance set at `closeBlock[e]` (indexer replays `Transfer` events; anyone can reproduce and compare `datasetHash`/`merkleRoot`), and keeper liveness. The contract enforces that only snapshot-proven wallets are paid, that the sum of proven balances never exceeds the committed total, and that pushes never exceed distributable inventory. A dishonest snapshot is therefore bounded to misallocating one epoch's inventory among proven leaves and is publicly detectable; it cannot drain the vault or pay an unproven address.

## 5. Indexer and public data shape

`indexer/` (Node ≥ 20, viem):
- `snapshot.mjs --epoch N --block B` → replays NASDAFUQ `Transfer` logs to block B, writes `indexer/out/<network>/snapshot-N.json` `{epoch, block, totalEligible, datasetHash, merkleRoot, leaves:[{wallet, balance, proof}]}`. Deterministic: wallets sorted ascending; `datasetHash = keccak256(abi.encode(address[], uint256[]))`; leaf = `keccak256(bytes.concat(keccak256(abi.encode(wallet, balance))))`; sorted-pair Merkle tree.
- `keeper.mjs` → closes epoch, commits snapshot, purchases, finalizes, pushes in batches, retries. Reads the key from `KEEPER_PRIVATE_KEY` env only; never writes it.
- `index.mjs` → replays all protocol events from `deployments/<network>.json` start block, writes `indexer/out/<network>/tracker.json` and copies it to `data/tracker.json` for the site.

`data/tracker.json` (site contract; every number is a decimal string in token base units unless suffixed):

```json
{
  "schemaVersion": 1,
  "generatedAt": "ISO-8601",
  "network": {"name": "robinhood-chain-testnet", "chainId": 46630, "explorer": "https://explorer.testnet.chain.robinhood.com"},
  "disclaimer": "Testnet mock assets. No monetary value. Not Stock Tokens.",
  "contracts": {"nasdafuq": "0x..", "feeVault": "0x..", "policy": "0x..", "snapshot": "0x..", "distributor": "0x..", "quote": "0x..", "chainlinkOracle": "0x..", "twapOracle": "0x.."},
  "basket": {"version": "0xhash", "assets": [{"symbol": "mDELL", "address": "0x..", "weightBps": 2000, "decimals": 18}]},
  "currentEpoch": {"epoch": 3, "state": "Open", "opensAt": 0, "closesAt": 0},
  "totals": {"grossFees": "..", "keeperReserve": "..", "carriedIn": "..", "netBudget": "..", "purchased": {"0xasset": ".."}, "pushed": {"0xasset": ".."}, "failedOutstanding": {"0xasset": ".."}, "rolledOverQuote": ".."},
  "epochs": [{
    "epoch": 1, "state": "Distributing", "basketVersion": "0x..", "snapshot": {"block": 0, "merkleRoot": "0x..", "totalEligible": "..", "datasetHash": "0x.."},
    "grossFees": "..", "carriedIn": "..", "keeperReserve": "..", "netBudget": "..",
    "assets": [{"address": "0x..", "symbol": "mFLY", "allocated": "..", "spent": "..", "expectedOut": "..", "purchased": "..", "distributable": "..", "pushed": "..", "failedOutstanding": "..", "rolledOverQuote": "..", "status": "purchased|rolled_over|paused", "clips": [{"kind": "drip|clip", "index": 1, "spent": "..", "expectedOut": "..", "purchased": "..", "tx": "0x..", "block": ".."}], "dripSpent": "..", "clipCount": 2}],
    "txs": {"close": "0x..", "snapshot": "0x..", "purchases": ["0x.."], "pushes": ["0x.."]}
  }],
  "transfers": [{"epoch": 1, "asset": "0x..", "symbol": "mDELL", "wallet": "0x..", "amount": "..", "status": "observed_onchain|failed|rolled_over", "attempt": 1, "tx": "0x.."}],
  "topWallets": [{"wallet": "0x..", "byAsset": {"0x..": ".."}, "quoteValueEstimate": "..", "valueLabel": "estimated"}],
  "alerts": [{"kind": "paused|failed|rolled_over|drip_skipped", "text": "..", "tx": "0x.."}],
  "dripSkips": [{"epoch": 0, "asset": "0x..", "symbol": "mFLY", "count": 2}]
}
```

Revision E additive fields (schema version stays 1): each asset row's `spent`/`expectedOut`/`purchased` are now SUMS across every drip (pre-close) and post-close clip on that `(epoch, asset)`; `clips` is the ordered detail behind that sum (`kind: "drip"` for `dripPurchase` clips run before close, `"clip"` for `purchase` clips run after); `dripSpent` and `clipCount` are convenience roll-ups of the same detail. A `DripSkipped` event becomes both a `drip_skipped` alert (with the reason) and a tally in the top-level `dripSkips` array. `contracts.chainlinkOracle`/`contracts.twapOracle` are present when the manifest carries them (testnet/local revision E deployments; see §11).

Status labels used by the site: `observed onchain` (from an event with a tx hash), `estimated` (any value derived by multiplying with mock rates, e.g. top-wallet value), `pending` (allocated but not purchased/pushed), `failed`, `rolled over`. `topWallets.quoteValueEstimate` divides cumulative reward amounts by the manifest's single mock adapter rate (`params.adapterRateWad`, shared by all five mocks in v1) and is always labelled `estimated`.

If `data/tracker.json` is missing or invalid the site shows an explicit unavailable state. It never shows demo numbers.

## 6. Decisions log

- D1 Foundry + OpenZeppelin 5.x; Solidity 0.8.2x; no other contract dependencies.
- D2 One vault contract holds both quote and reward inventory; `PushDistributor` is the proof/batch layer. Custody and the paid ledger live in one place so double-payment is enforced by a single mapping.
- D3 Unpurchasable allocation rule: quote stays in the vault and rolls into the next epoch's `carriedIn` (visible via `AllocationRolledOver`). It never becomes a sixth asset.
- D4 Failed push rule: retry up to `maxRetries`; then admin may roll the amount into `inheritedInventory[a]` for the next epoch's holders. Never to an address.
- D5 Keeper reserve: fixed bps of gross, withdrawable only to an immutable treasury address.
- D6 KeeperRegistry and SafetyController are separate small contracts to keep role seams explicit.
- D7 Testnet reward-token mocks are minted to the `MockRouteAdapter` at deploy; the adapter "sells" from that inventory.
- D8 Site data path = static JSON produced by the indexer and committed under `data/`. No live RPC from the browser in v1 (public RPC is rate-limited and the site must not become an authority).

## 7. Revision A — threat-model critique outcomes (2026-09-16)

`docs/THREAT_MODEL.md` (independent opus critique) judged revision 0 FAIL with ten gaps. Dispositions:
1. totalEligible unbound / pushed>distributable → HARD `require`s + `provenBalanceSum` (accepted).
2. released inventory double-counted → `released[e][a]` + inheritedInventory zeroed on fold (accepted).
3. invariant 1 strict equality / undefined fields → `>=`, `carryPending` defined (accepted).
4. purchase once-only + completeness gate → `resolved`, `resolvedCount == 5` (accepted).
5. adapter allowance → exact `forceApprove` before, zero after, both deltas measured (accepted).
6. protections only in adapter / no oracle seam → vault-side `IPriceOracle` (mock in v1) with staleness + deviation checks (accepted; real feed is a production gate).
7. snapshotBlock unbound → `snapshotBlock == closeBlock[e]` (accepted).
8. pause-then-sweep escape + recovery scope → time-based rollover REMOVED entirely (rollover only after `maxRetries` failures); recovery keyed on append-only `everRegistered` (accepted, simplified).
9. event completeness, `attempt` on push events, reserve bps mutable → full list adopted; `keeperReserveBps` immutable and capped at 2000 (accepted).
10. keeper-gated pushes = censorship; minPayout; gas-bomb token → pushes permissionless and proof-gated; `minPayout` releases dust; bounded low-level transfer (accepted).
Consequence of 8+10: `finalizeEpoch` and dust sweeping are dropped. States end at `Distributing`.

## 8. Revision B — adversarial review round 1 outcomes (2026-09-16)

`docs/REVIEW_CONTRACTS_R1.md` (independent opus review with failing probes) confirmed seven bugs in the first implementation. All are fixed; the probes in `contracts/test/review/Review.t.sol` now assert the fixed behaviour. Rules that changed:

- **Purchase fail-closed, refined.** Every pre-buy read (controller, registry, oracle, adapter quote/liquidity) is wrapped in `try/catch`; any revert resolves as a rollover with a reason (`controller_unreachable`, `registry_unreachable`, `oracle_unreachable`, `adapter_quote_revert`, `adapter_liquidity_revert`). A zero oracle rate or zero expected output resolves as a rollover (`oracle_zero`, `expected_zero`) before any quote moves. The one case that **does** revert the purchase tx is a balance-delta mismatch after an adapter reported success (`DeltaMismatch`): quote may already have left, so booking a rollover would invent carry. The allocation stays unresolved; the keeper may retry after the route is fixed, or the admin marks the asset non-Active so the next `purchase` rolls it over cleanly. §3's sentence "the vault never reverts the epoch" is superseded by this paragraph.
- **Proven balance binding.** `PushDistributor.provenBalance[e][wallet]` records the first proven balance; any later leaf for the same wallet must carry the identical balance (`ProvenBalanceMismatch`). This closes the gap where a failed first push let a second, larger leaf be paid.
- **Push skips are observable and never revert the batch.** `RewardPushSkipped(e, a, wallet, amount, reason)` with `retry_cap` or `exceeds_distributable`; `pushReward` returns false.
- **Bounded transfer strictness.** A codeless token address is a failure; only empty returndata or a 32-byte word equal to 1 is success.

## 9. Revision C — whole-product review outcomes (2026-09-16)

Independent opus whole-product review: 24/27 criteria PASS, testnet deployment BLOCKED, two documentation FAILs. Fixes: feed-operator row added to §4 and the runbook; `FEED_OPERATOR_ADDRESS` in the deploy script with transferable mock operators so the disposable deployer key can be discarded; `docs/THREAT_MODEL.md` and `docs/REVIEW_CONTRACTS_R1.md` carry status banners; §5 schema synced (`contracts.nasdafuq`, `totals.carriedIn`, no `Finalized` state); `closeEpoch` re-checks that policy weights sum to 10 000 (future policy seam); `PushDistributor` checks the pause flag before proof work; below-minimum re-push emits `RewardPushSkipped("below_minimum")`; `data/tracker.json` is git-ignored until a non-local ledger exists; deploy runbook uses a keystore account instead of a key on the command line.

## 10. Revision D — Arbitrum-family block numbers (2026-09-17, first testnet run)

On Robinhood Chain (an Arbitrum-style rollup) `block.number` inside the EVM returns the **parent chain's** block number (Ethereum Sepolia, ≈11.7M on testnet), while `eth_getLogs` and receipts use the chain's own block numbers (≈120M). The first testnet epoch close exposed this: `closeBlock[e]` was a Sepolia number and the snapshot replay found no transfers.

Rule adopted: the contract keeps binding the commit to `closeBlock[e]` (whatever `block.number` returns; it only needs to be a stable, monotonically increasing value), and the **dataset is defined at the chain-native block that contains `EpochClosed(e)`**. That block is derived from the event itself, so the snapshot stays reproducible from logs. The snapshot file records both (`block` = committed value, `replayBlock` = chain-native replay block). On anvil the two coincide. `Deploy.s.sol` records `startBlock` via the ArbSys precompile (`0x64.arbBlockNumber()`) when present, falling back to `block.number`. The testnet manifest's `startBlock` was corrected by hand to the chain-native block of the first deploy receipt (120627007). `purchase` in the keeper skips already-resolved assets and `scripts/e2e-testnet.sh` is state-aware so an interrupted run resumes where the chain is.

## 11. Revision E — basket v2, clip purchases, drip buys, real oracle seams (2026-09-17)

**Basket v2 (decision, after the liquidity screen in `docs/LIQUIDITY_SCREEN_2026-09-17.md`):** `DELL / AMD / FLY / USAR / QQQ`, 2000 bps each. DELL, AMD, USAR, QQQ have Chainlink feeds on Robinhood Chain and fill 10k USDG inside 1%; FLY is the deliberate degen slot (space launch): no feed, ~0.3% impact at 1k USDG, ~4% at 10k. The name still spells DAFUQ. Testnet mocks become `mDELL mAMD mFLY mUSAR mQQQ`. QQQ is a tokenized ETF; copy must say "Stock Tokens & Tokenized ETFs".

**Clip purchases.** Per asset (AssetRegistry, admin-set, emitted): `maxClipQuote` (0 = unlimited) and `minClipInterval` (seconds). `purchase(e, asset)` after close spends `min(remaining allocation, maxClipQuote)` per call and may be called repeatedly until the allocation is spent; each successful clip emits `Purchased(e, asset, spent, expectedOut, purchased, clipIndex)`. A failed clip resolves the asset immediately: the unspent remainder rolls over (`AllocationRolledOver`), purchased so far is kept. `finalizePurchases` still requires all five resolved.

**Drip purchases (FLY).** `dripPurchase(asset)` — keeper, epoch Open, asset must have `maxClipQuote > 0` — spends `min(maxClipQuote, dripCap − dripSpent)` where `dripCap = grossFees[e] × (10000 − keeperReserveBps) / 10000 × weight / 10000`, i.e. 20% of net fees *already received*, never an estimate. `minClipInterval` enforced against the last clip. Drip spend/purchases are recorded on the open epoch; at `closeEpoch`, `spent[e][a]` and `purchased[e][a]` start from the drip totals and the post-close clips spend `allocated − dripSpent`. Drip spend is bounded above by the eventual allocation because gross only grows and the reserve is applied. Events: `DripPurchased(e, asset, clipIndex, spent, expectedOut, purchased)`, `DripSkipped(e, asset, reason)`.

**Oracle seams.** `IPriceOracle` stays. Two production-shaped implementations plus mocks: `ChainlinkPriceOracle` (AggregatorV3 `latestRoundData`, staleness from `updatedAt`, decimals normalised to 18, `answer > 0`) and `TwapPriceOracle` (Uniswap v3 `observe` over a window with a spot-vs-TWAP divergence bound; returns the TWAP as the rate, and `updatedAt = block.timestamp` only if the divergence check passes, otherwise a stale timestamp so the vault rolls over). The vault's per-asset oracle comes from the registry (`oracleOf(asset)`), so DELL/AMD/USAR/QQQ use Chainlink and FLY uses the TWAP. On testnet both are exercised through mocks and a mock v3 pool with a settable `observe`.

**Keeper.** `keeper.mjs loop --drip FLY --drip-every 900 --epoch-every 3600` runs the cycle; on testnet the demo uses 15 s / 60 s. Invariant 1 gains a `− dripSpent[current]` term on the open epoch.

Nothing in this revision changes the authority map: admin sets clip parameters and oracles (timelocked where they were), keepers only trigger, amounts are still computed onchain.

**Implementation notes (deviations from the text above, contracts implementation, 2026-09-17):**
- On testnet, DELL/AMD/USAR/QQQ and FLY all keep `MockPriceOracle` as their live `oracleOf` (unchanged operator-settable-rate workflow) rather than actually wiring Chainlink/TWAP as the registered oracle. `ChainlinkPriceOracle` (4 `MockAggregatorV3` feeds) and `TwapPriceOracle` (1 `MockV3Pool`, FLY-shaped config) are deployed and pre-configured alongside so both seams are addressable and callable on testnet (manifest `contracts.chainlinkOracle`/`contracts.twapOracle`); rotating an asset onto either is one `SafetyController.queueOracleRotation`/`executeOracleRotation` pair away.
- `TwapPriceOracle`'s tick→price conversion is a WAD fixed-point `1.0001^tick` via exponentiation by squaring, not Uniswap's TickMath bit-shift table, and it treats that price as the asset-per-quote rate directly with no token0/token1 decimal reordering. Accurate and overflow-free across the realistic Uniswap tick range (±887272); flagged with a `ponytail:` comment as the simplification's ceiling.
- Added `dripExpectedOut[e][a]` (not in the storage list above) purely so post-close slippage reporting (`expectedOut − purchased`) stays meaningful when part of an allocation was bought during the drip phase; folded into `expectedOut[e][a]` at `closeEpoch` the same way `dripSpent`/`dripPurchased` fold into `spent`/`purchased`.
- Added a defensive `require(dripSpent <= allocated, "drip exceeds allocation")` at the `closeEpoch` seeding step and a public `dripCapOf(e, asset)` view (indexer/test convenience) — both are safety/ergonomics additions, not new protocol rules.
- `purchase(e, asset)` reverts with a new `ClipTooEarly()` error when `minClipInterval` hasn't elapsed, rather than emitting a skip event — `dripPurchase` is the keeper-loop-safe, never-revert path; `purchase` is keeper-explicit and already reverts on other misuse (`AlreadyResolved`, `NotInBasket`), so a plain revert matches its existing style.

## 12. Revision F — review round 2 outcomes (2026-09-17)

`docs/REVIEW_CONTRACTS_R2.md` (independent opus review of revision E, 31 probes in `test/review/ReviewV2.t.sol`) confirmed seven issues; all fixed the same day and the probes now assert the fixed behaviour (193/193 tests):

1. **All-drip epoch could never finalize** (HIGH) → `closeEpoch` opens the epoch directly in `Purchasing` when drips already resolved every asset.
2. **Codeless oracle/adapter reverted `purchase`** → `code.length == 0` checks resolve as rollovers (`oracle_no_code`, `adapter_no_code`).
3. **Staleness arithmetic could overflow / future stamps counted as fresh** → `updatedAt > block.timestamp` rolls over as `oracle_future`; subtraction order avoids overflow.
4. **Unbounded clip interval** → `minClipInterval ≤ 1 day` enforced in `setClipParams`; interval math done in uint256; authority-map row added (§4).
5. **A mutable policy could brick `closeEpoch`** → the basket (assets, weights, version) is now frozen when an epoch OPENS (`_snapshotBasket`), so drip caps and allocations always share weights and a policy change applies from the next epoch. The `dripSpent ≤ allocated` require is now unreachable by construction and kept as an internal assertion.
6. **TWAP negative ticks lost precision** → negative ticks exponentiate the inverse base directly; price is exact to the WAD floor.
7. **Divergence signalled via a zero timestamp** was inert on young chains → the TWAP oracle returns a **zero rate** on divergence; the vault resolves it as `oracle_zero` regardless of chain age.

Coverage note from the review: neither production-shaped oracle is registered as an asset's live `oracleOf` in the shipped suites (unit-tested in isolation; exercised end to end only through the review probes' rotation helper). Wiring them live remains part of gate G2.
