Build the off-chain payout engine for "IMD Money Back" ($MONEYBACK): a TypeScript service that reads Robinhood Chain (4663), finds every 15 min which holders are underwater and pays them back in IMD via RoundPayout. TypeScript + tsx + viem + vitest, Linux VPS under pm2. Deliver: package.json, tsconfig, src/, test/, scripts/, env.example, ecosystem.config.cjs, launch.example.json, README. No frontend, no Solidity.
ON-CHAIN FACTS (addresses from launch.json at runtime; hardcode only IMD):
- Pool: MONEYBACK/IMD v4 pool on PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951. IMD = 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 (18 dec). Read currency0/1 order from the Initialize log, never assume it.
- MoneyBackHook: 4.25% of the IMD leg of every swap plus a sell surcharge decaying 20%->0 over the first 30 min; fees accrue in the PoolManager; permissionless sweep() sends them to the payout wallet. Events FeeAccrued(isSell, baseFeeImd, surchargeImd, imdLeg), Swept(imdAmount, to).
- RoundPayout (owner = payout wallet): fund(token, amount); payRound(roundId, token, to[], amounts[], ledgerHash, twapCloseX96, totalEligibleLoss), idempotent per roundId, per-leg failures recorded without reverting; retryFailed(roundId, to[]); writeOffFailed(roundId, to); views isPaid, rounds, failed, paused; events incl. RoundPaid. ABI ships with launch.json.
- Fixed 1.25% LP fee: 1% reaches the payout wallet via the launch Merkle distributor (anyone can trigger; address in launch.json), 0.25% to the network. Published split 4.5% pouch / 0.75% team / 0.25% network, so the engine books 25% of each distributor receipt to the pouch, 75% to team.
- MoneyBackRouter lets buyers pay ETH (ETH->IMD on the public pool, IMD->MONEYBACK on ours, one tx): to the engine an ordinary Swap on our pool, basis = IMD leg; router (in launch.json) excluded from eligibility.
- No bonding curve. ~10% of supply is airdropped to swarm seats; no cost basis.
PRODUCT RULES (locked):
- Epochs of 900 s on a fixed grid from launchTs. Close = 3-minute TWAP of IMD per MONEYBACK ending at the boundary, from Swap observations; an empty window carries the last price; same timestamp: last wins.
- Qualifying buy = a tx where the wallet balance rose, tokens left OUR pool (Swap on our pool id) AND our hook emitted FeeAccrued in that tx. Tokens arriving any other way (other pools, OTC, transfers, airdrop) have zero basis, never earn, do not void cover. Eligible = still holds every qualifying token and qualifying buys >= minBuy (IMD). Disqualified forever = any balance decrease (sell on any venue or transfer out) or a contract/router/pool/hook/system address. Buying more via our pool is fine.
- Cost basis = IMD spent incl. all fees, pro-rata to tokens delivered; entry = VWAP across buys. Loss = max(0, (entryVWAP - close) * heldQualifyingTokens), in IMD.
- Each drop pays a wallet at most 1/3 of its CURRENT loss (maxRoundRatio); cumulative payments never exceed the loss (maxPayoutRatio = 1, made whole); once covered, nothing until a new loss appears.
- Pot for a drop = IMD from hook sweeps since the last drop, plus 25% of IMD received from the distributor since then (its Transfer logs; other inflows never count), minus optional ops skim (OPS_SKIM_FRACTION) and one-time DEX reserve (DEX_RESERVE_QUOTE), both default 0. The other 75% is team share: shown in treasury, never paid. Split across eligible wallets by weight = loss^a * pct^b, a=1, b=0 (pure loss share), each capped by rule 4; leftover rolls forward; skip legs under minPayout.
- One payRound tx per chunk of at most maxRecipientsPerTx (default 200); roundId = epochIndex*1000 + chunkIndex. One ledger JSON per drop (payee, loss, entry, close, amount); ledgerHash = keccak256 of the canonical ledger, passed to payRound.
- Exclusions: deployer, factory, distributor, PoolManager, hook, router, RoundPayout, payout wallet, dead address, any address with code, plus a configurable list.
MODULES (one file each in src/): config (launch.json + env; RULES/OPS/EXEC/RPC knobs, defaults above); rpc (round-robin with backoff; RPC_LOGS_URL for getLogs; RPC_PRIORITY_URL pinned for all reads in a payout round; chunked getLogs bisecting refused ranges; LOWEST head across endpoints minus followMargin; timestamp interpolation between anchor headers ~100 blocks apart, exact at epoch/TWAP boundaries; disk cache); indexer (backfill from launchBlock then follow head; token Transfer, Swap by pool id, Initialize, FeeAccrued/Swept, IMD Transfers into the payout wallet; group by tx; rebuild every wallet ledger deterministically into data/); twap, eligibility, payout (pure: weights, caps, chunking, ledger hash); ledger; rounds (per epoch: sweep -> bookClaim -> compute -> execute; stall monitor; one round at a time); fees (hook.sweep(), pending(), trigger the distributor each epoch so the 1% is realised); executor (live signer: sweep, approve+fund RoundPayout, PROVE funding by receipt log AND a priority-node balance read before paying, payRound in chunks, re-check isPaid per send, retryFailed/writeOff, pause file halts sends, maxRoundPayoutQuote cap refuses + alerts on an absurd round); wallet (PAYOUT_PRIVATE_KEY from env only; nonces; send with timeout + confirmations; key never logged or on disk); snapshot (writes data/.json: token, status, treasury, rounds, chart, leaderboard, wallets, ledgers; status.json carries prices {imdUsd, ethUsd, tokenUsd, source, at} from RH ETH/IMD pool spot + Dexscreener/CoinGecko ETH/USD every 60 s, last good kept on failure; engine pays in IMD only); api (GET /api/status; GET /api/wallet/:address -> eligibility, entry, loss, paid, next-drop estimate via the SAME eligibility code; static /data/); alerts (Telegram on paid/failed/stalled + heartbeat); lock (single writer). scripts/: discover-launch.ts (from the token address find Initialize log, pool id, hook, distributor, launchBlock/Ts/Tx; print launch.json fields), reconcile-claim.ts (record an out-of-band sweep), replay.ts (recompute all drops from logs; diff vs on-chain RoundPaid).
OPERATIONS: EXECUTOR=dry-run by default (computes, writes ledgers, sends nothing); EXECUTOR=live sends. RH has ~0.1 s blocks: VIEM_POLL_MS 750, anchorSpacing 100, confirmationBlocks 5. pm2 apps: rounds (executor + in-process indexer), snapshot, data (serves data/ and /api internally). env.example lists every variable, commented. README: install, configure, dry-run backfill, go live, alerts, read a ledger, verify a drop on-chain.
TESTS (vitest, offline): twap (window, carry, same timestamp); eligibility (fee-paid buys only, other-pool/OTC uncounted, any-venue sell/transfer void, minBuy, airdrop, exclusions); payout (1/3 cap, made-whole cap, split, leftover roll, chunking, roundId, ledger hash); bookClaim (sweep + 25% distributor -> pot, 75% team, ops skim, DEX reserve); indexer tx-grouping fixtures for all four swap cases; executor state machine on a mocked chain (funding proof required; lagging-node zero balance must not fail a round; isPaid re-check; pause file).
ACCEPTANCE: npm test passes; replay.ts on fixture logs reproduces expected ledgers byte-for-byte; a live-pool dry run backfills and prints the next epoch's would-be payouts; the private key appears nowhere in logs or data.