# X Payday technical reference > New here? Start with the visual guide at `/guide` — diagrams first, short captions, every step > ends in a call to action. This file is the full technical reference: come back to it when you > need to verify a claim, an address or a fee. This is the canonical technical description of X Payday: what it is, exactly how a dollar moves from a trade on a Pons V2 bonding curve to a payment in someone's X Money account, what is enforced on chain versus what depends on a human, and how to check every claim below yourself. It is written for a person auditing the product and for an AI agent reading the site and trying to describe it accurately. Every fact below names the file it came from. Where a value could change on chain (a fee, a threshold, an address), the file and the exact getter to re-read it live are both given, because a document goes stale and a chain read does not. This document describes the code in this repository. It is not a marketing page and it makes no claims about usage, volume or traction: read the live API (`GET /status`, `GET /vaults`, `GET /activity`) and the chain for those. The one place the brand is hardcoded is `packages/shared/src/brand.ts`; every page and this file derive their copy from it. Status as of this writing (2026-09-13): the contracts under `contracts/src/` compile, the shared/API packages are complete, and the X Payday contracts are deployed to Robinhood Chain mainnet (chain 4663) and **Sourcify exact-match verified**. The addresses, roles and live configuration below are read from `contracts/deployments/4663.json` and the chain. A local deployment also exists on an anvil fork of chain 4663 (`contracts/deployments/local.json`). ## Links | What | Value | |---|---| | Domain | [`xpayday.money`](https://xpayday.money) | | X account | [`@xpayday`](https://x.com/xpaydaymoney) | | Factory (`HandleVaultFactory`) | [`0xbFd7945e9Dd1C13eD4B5cDAb133a0fCc4F0c3032`](https://hoodscan.co/address/0xbFd7945e9Dd1C13eD4B5cDAb133a0fCc4F0c3032) — chain 4663 mainnet, deployed at block 62414357 (`contracts/deployments/4663.json`) | | Local factory (anvil fork, not public) | `0x8ac87219a0F5639BC01b470F87BA2b26356CB2B9` (`contracts/deployments/local.json`) | Two previous factory deployments are superseded and never used: `0x2B6DcfebA0053b6F1e554CFbb88ce0751D222eF6` and `0xbA62739B111Fe0253163Fcd9dE3d5B6A7C1988f6`. The brand values live only in `packages/shared/src/brand.ts` (`BRAND.domain`, `BRAND.url`, `BRAND.handle`). The deployable addresses are written by the deploy scripts to `contracts/deployments/.json` (`script/Deploy.s.sol`) and `contracts/deployments/local.json` (`script/DeployLocal.s.sol`). If this table and a deployment record ever disagree, the record on disk wins and this table is stale. ## What X Payday is X Payday lets anyone launch a Pons V2 bonding-curve coin *for* an X handle. The coin's on-chain creator fee recipient is not a person's wallet and not X Payday's wallet: it is a per-coin `FeeSplitter` that receives every creator fee Pons credits and divides it, by basis points of trade volume pinned at launch, into the handle's on-chain pot (a per-`xUserId` `HandleVault`), the holders' pool (a per-coin trustless `StakingRewards` stream), and the platform treasury. The handle owner proves ownership by signing in with X once; an oracle-signed EIP-712 `Attestation` then lets anyone submit their `CLAIM` (pay any amount to any wallet), `SET_PAYOUT` (auto-pay future fees to a wallet after a 24-hour notice), `LAUNCH_LOCK` (freeze the handle against further launches), or `CANCEL_DELIVERY`. Anyone may also ask X Payday to deliver an unclaimed pot to the owner's X Money account for a 3% fee, but only after a 24-hour owner window, and the last mile is a human send with a public proof. A pot nobody touches for 180 days flows to that coin's holders' pool, never to the treasury. The primary payout path is the plain on-chain claim to any address the owner names — most simply the USDG deposit address of a Robinhood Crypto account on Robinhood Chain (`CLAIM`, 0% fee); the community delivery to X Money is the fallback for US handles without a Robinhood account. Every fee split is arithmetic on a base-unit balance: the contracts are the source of truth, and this document is a pointer to them. ## Getting started without a wallet X Payday does not require a crypto wallet. `/start` (`apps/web/app/start/page.tsx`) is a plain-language onboarding page with five short sections — what a pot is, how to get paid (a claim to a Robinhood USDG deposit address, one-click X Money delivery, or a wallet), how to buy a coin with USDG on Robinhood Chain, how to buy with Robinhood Wallet, and what X Payday never does — and links into `/claim` and `/launch`. Every primary call to action is **Sign in with X** first; the wallet is optional and is labelled **Advanced** wherever it appears. Three paths work with **no wallet at all**: - **Launch for any handle (sponsored).** A signed-in X user can create a coin for any handle at `POST /launch/sponsored`. X Payday's keeper submits the on-chain transaction and pays the Pons launch fee; the user needs no wallet and no ETH. Costs, caps and limits are in "The end-to-end flow" and "Cost of sponsoring a launch". - **Claim to a Robinhood USDG deposit address.** A signed-in handle owner can claim the pot to the USDG deposit address of a Robinhood Crypto account on Robinhood Chain — no wallet, no bridge, no fee. Because a contract-origin deposit is unverified, the card offers a **$1 test** first; see "Claim to Robinhood (custodial) and Robinhood Wallet (self-custody)". - **Deliver to X Money.** The alternative for US handles without a Robinhood account is one-click delivery to X Money: a 3% fee, a 24-hour owner window, and a human last mile with a public proof. An owner whose X profile does not confirm the US (the `eligibility` hint is `non_us` or `unknown`) sees **Claim to a wallet** only, plus a three-step explainer on getting a Robinhood Chain wallet — no promise X Payday cannot keep. A wallet stays available as the labelled **Advanced** option for launching yourself, buying the coin, claiming to an address you control, and setting auto-pay. A Robinhood Wallet (Robinhood's self-custody app) supports chain 4663 natively and over WalletConnect, so it can be that wallet. ## The end-to-end flow Every hop, named, in order. Contract function names and module paths are exact; amounts move only where the named function moves them. 1. **A handle is resolved to its numeric X user id (off chain).** `GET /handles/:handle` (`apps/api/src/handles/routes.ts`, resolution in `apps/api/src/handles/resolve.ts`) resolves the lowercase handle to a numeric `xUserId` through the X API v2 when `X_BEARER_TOKEN` is set, otherwise through fxtwitter (`apps/api/src/handles/fxtwitter.ts`), with a 24-hour cache (`HANDLE_CACHE_TTL_SECONDS`). A vault is keyed by `xUserId`, never by the handle string, because a handle can be released and re-registered but a numeric id cannot. 2. **A launcher gets an oracle-signed launch ticket.** `GET /launch/challenge?address=` returns an EIP-191 challenge (`apps/api/src/signer/challenges.ts`); the wallet signs it and `POST /launch/ticket` verifies the signature, resolves the handle, refuses locked handles (`409 LAUNCH_LOCKED`) or a mismatched caller-supplied `xUserId`, and returns an oracle-signed EIP-712 `LaunchTicket` bound to `(handleHash, xUserId, launcher, deadline, oracleEpoch, nonce)`. The ticket's deadline is issued 10 minutes out; the factory additionally refuses a deadline more than `MAX_TICKET_TTL` (1 hour) past the consuming block. 3. **A vault is created and the coin launches.** `HandleVaultFactory.createVaultAndLaunch(handle, xUserId, LaunchParams, LaunchTicket, sig)` (payable) calls the private `_createVault`, which: reverts `Paused` when paused, `VaultExists` if `vaultOf[xUserId] != 0`, `HandleNotLowercase` on any A-Z byte, `HandleMismatch` if `keccak256(bytes(handle)) != t.handleHash`; runs the same checks as `verifyAndConsumeTicket` (its private `_verifyAndConsume`) to validate the oracle signature and mark the nonce used; deploys `Clones.cloneDeterministic(vaultImplementation, bytes32(xUserId))`; calls `HandleVault.initialize(factory, xUserId, handleHash, handle)`; registers the vault and emits `VaultCreated`. It then calls `HandleVault.launchFor{value: msg.value}(p, launcher)`. The one ticket authorizes both the creation and the launch. (Alternatively `createVault` then `HandleVault.launch(p, t, sig)`, where the public launch path itself consumes the caller's ticket via `HandleVaultFactory.verifyAndConsumeTicket`.) `LaunchParams` (`contracts/src/libs/Structs.sol`) carries the coin's metadata and pinned economics: `{ string name; string symbol; string metadataURI; uint16 creatorTaxBps; bool reflections; string description; string website; string twitter; string telegram; string discord; string github; }`. The six trailing strings are optional profile fields: `description` becomes the token description, `website`/`twitter`/`telegram`/`discord` map onto Pons `Socials` (`github` has no Pons slot, so the vault appends it to the description as a trailing `"\nGitHub: "` line when non-empty, and the indexer peels it back off). **No-wallet alternative to hops 2–3: the sponsored launch.** A signed-in X user (session cookie from X OAuth) can launch a coin for **any** handle without a wallet by POSTing `{ handle, name, symbol, metadataURI?, creatorTaxBps?, reflections? }` to `POST /launch/sponsored` (`apps/api/src/signer/sponsored.ts`). The API resolves the handle, refuses a locked handle (`409 LAUNCH_LOCKED`) and a disabled or over-cap path (`503 SPONSORED_DISABLED`, `429 SPONSORED_LIMIT`), then issues the **same** EIP-712 `LaunchTicket` struct with `launcher` set to the **keeper address**, and enqueues a relay job of kind `sponsoredLaunch`. The keeper submits `HandleVaultFactory.createVaultAndLaunch` — or `HandleVault.launch` when a vault for that handle already exists — from the keeper key with `value = PonsV2LaunchFactory.launchFee()`. The response is `{ jobId }`; poll `GET /relay/:jobId`, whose confirmed `result` carries the `vault`, `token` and `curve` parsed from the receipt's `Launched` event. Defaults are `creatorTaxBps = 200` (2%) and `reflections = true`. The launcher pays **no** fee and needs **no** ETH: X Payday's keeper pays the Pons launch fee (see "Cost of sponsoring a launch"). Limits: one sponsored launch per `xUserId` per 24 hours, plus the global `SPONSORED_LAUNCHES_PER_DAY` cap (default 50). A sponsored launch launches the coin; it does **not** buy any of it, because buying needs a wallet. Hop 3's on-chain checks are otherwise unchanged — only the launcher identity and who pays the fee differ. 4. **The vault deploys the coin's pools and pins the split.** `HandleVault._openPools` (`contracts/src/HandleVault.sol`) validates the tax (`TaxBelowFloor`, `TaxAboveMax` against the live `PonsV2LaunchFactory.maxCreatorTaxBps()`, `TaxNotOnGrid` when reflections are on and `tax % 50 != 0`, `TaxPinned`/`ReflectionsPinned` if a previous launch pinned different values), clones a `FeeSplitter` and — when reflections are on — a `StakingRewards`, calls `FeeSplitter.initialize(vault, staking, ponsFactory, ponsEscrow, pairToken, R, P, C)`, reads `PonsV2LaunchFactory.launchFee()`, and calls `FeeSplitter.launch{value: fee}(params, 0, USDG)`. 5. **The splitter launches on Pons as the coin's creator fee recipient.** `FeeSplitter.launch` (`contracts/src/FeeSplitter.sol`) forces `TokenParams.creatorFeeRecipient = address(this)` and `buybackEnabled = false`, then calls `PonsV2LaunchFactory.launchToken{value: msg.value}(params, 0, USDG)` and records the returned `(token, curve)`. The splitter, not the vault, is Pons's `originalDeployer`, which is what makes the coin's escrow claimable: `PonsV2FeeEscrow` keys balances strictly to `msg.sender` (see `contracts/research/PONS-NOTES.md`). The vault refunds any `msg.value` above the exact Pons `launchFee()` to the launcher and emits `Launched`. 6. **Trades accrue fees on the curve, then in Pons's escrow.** Curve trades build up `quoteFeeBalance()` (the base curve fee bucket) and `creatorTaxBalance()` (the creator tax, paid to the creator in full). `HandleVault.claimFees()` (permissionless) calls each coin's `FeeSplitter.collect()`, which first calls `curve.sweepFees(0)` (best-effort) to move those bucket balances into `PonsV2FeeEscrow`, credited to the splitter. `FeeSplitter.collect()` additionally calls the Pons meme hook `sweepPoolFees(poolId, 0, 0)` best-effort so post-graduation (Uniswap v4) creator fees keep flowing; memecoin-denominated post-graduation fees still require Pons's fee-sweep operator. 7. **`FeeSplitter.collect()` divides the inflow.** It pulls the splitter's escrow balances with `claimToken(pairToken)` and `claim()` (the native leg) and forwards any native balance to the vault — all best-effort, so a curve/escrow revert cannot brick distribution. For an inflow `x = pairToken.balanceOf(this)`: `toPlatform = mulDiv(x, P, R)`, `toHolders = mulDiv(x, C, R)` (only when `staking != 0 && C != 0`), and `toVault = x - toPlatform - toHolders` (the remainder, so no dust is stranded). It transfers `toPlatform` to `factory.treasury()` **read live**, transfers `toHolders` to the staking pool via `notifyRewardAmount`, transfers `toVault` to the vault, and emits `Collected(toVault, toHolders, toPlatform)`. `HandleVault.claimFees` sums those legs and emits `FeesClaimed(toVault, toHolders, toPlatform)`. 8. **The owner acts through an oracle attestation.** `POST /claim/attest` (session-scoped) verifies the signed-in `xUserId` owns the vault (`vault.xUserId()`), reads `claimNonce` and `oracleEpoch` live, and returns a signature over the vault's own EIP-712 domain (`apps/api/src/signer/attestations.ts`). `HandleVault.execute(a, sig)` checks the signature, `handleHash`, `xUserId`, epoch, nonce and deadline (≤ 1 hour out), increments `claimNonce`, and dispatches: - `CLAIM` → `_claim(to, amount)`: transfers `amount` (0 means the whole `available()`) to `to`, cancels any active delivery, emits `Claimed(to, amount, nonce)`; reverts `NothingToClaim` on an empty pot. - `SET_PAYOUT` → `_setPayout(to)`: `to == 0` clears immediately and emits `PayoutAddressSet(0)`; otherwise sets `pendingPayoutAddress = to`, `pendingPayoutActivatesAt = now + 24h`, emits `PayoutAddressProposed`. Anyone later calls `activatePayout()` after the delay to make it live (`PayoutAddressSet(to)`). - `LAUNCH_LOCK` → `_setLaunchLock(amount)`: `amount == 1` locks, `0` unlocks; emits `LaunchLockSet(bool)`. - `CANCEL_DELIVERY` → cancels the active delivery; emits `DeliveryCancelled`. 9. **Auto-pay pushes future fees.** With an activated `payoutAddress`, anyone calls `HandleVault.payout()` (permissionless) and the whole current `available()` is transferred to that address (`PaidOut(to, amount)`). The keeper does this automatically (`apps/api/src/keeper/tick.ts`). Calling `payout()` is a no-op when auto-pay is off or the pot is empty, and is never blocked by pause. 10. **Community delivery to X Money.** Anyone calls `HandleVault.requestDelivery()` (permissionless; reverts `PayoutAddressConfigured` when auto-pay is set, `DeliveryActive`, `NothingToDeliver` on an empty pot, `Paused`). It snapshots `feeBps` (live `deliveryFeeBps()`), `wallet` (live `deliveryWallet()`) and `eta = now + deliveryDelay()` into the `Delivery` record and emits `DeliveryRequested`. The owner keeps the whole owner window (the delay) to claim first or cancel via `CANCEL_DELIVERY`; the guardian can also `cancelDelivery()`. After `eta`, `HandleVault.executeDelivery()` may be called by `factory.deliveryOperator()` only: it pays `fee = amount * d.feeBps / 10000` to `factory.treasury()` (live) and the rest to the snapshotted `d.wallet`, then emits `DeliveryExecuted(requestId, amount, fee)`. 11. **The human last mile, with public proof.** The delivered USDG sits in `deliveryWallet`. A human sends the equivalent to the handle owner's X Money account from X Payday's own X account (`@xpayday`), then records the send in the ops console (`POST /ops/deliveries/:id/sent { proofUrl, note }`), which marks the delivery `sent` and inserts a `delivery_sent` activity event with the proof URL and note. There is no X Money API and no automated fiat rail in this build; this step is deliberately manual and is what the public proof attests to. 12. **The activity feed is event-sourced.** `apps/api/src/indexer/` decodes factory, vault, splitter and staking logs into the `events` table, and `GET /activity` serves `EventDTO` rows. A `claimed` row only ever comes from a `Claimed` event with its tx hash; a `delivery_sent` row only from the ops record. There is no fabricated history. The whole chain, compressed to one line: trade on the Pons curve → curve fee buckets → `FeeSplitter.collect()` (`sweepFees` + escrow claim) → platform/holders/pot split → `HandleVault` pot → owner `execute(CLAIM)` to a wallet, or `execute(SET_PAYOUT)` + `payout()` auto-pay, or `requestDelivery()` → `executeDelivery()` → `deliveryWallet` → a human X Money send → a public proof. ## The fee model, exactly Every number is pinned per coin at its first launch except where noted, and is in basis points (1 bps = 0.01%) of trade volume. The formula below is the Solidity in `contracts/src/HandleVault.sol` (`_openPools`) and `contracts/src/FeeSplitter.sol` (`collect`), reproduced exactly by `packages/shared/src/fees.ts` (`splitBps`) for the UI. Integer division floors; the vault always gets the remainder. Let `T` = `creatorTaxBps` (the launcher's chosen creator tax), `FLOOR = 100` (the hardcoded floor constant in the vault), and `PONS_CREATOR_BASE_BPS = 70` (the creator's share of Pons's 1% base fee). Then: - **`R = T + 70`** — the total bps of volume the `FeeSplitter` receives as Pons's creator fee recipient. - **`P = FLOOR * platformShareOfFloorBps / 10000`** — the platform's share, `0.15%` (15 bps) at the default `platformShareOfFloorBps = 1500`. `MAX_PLATFORM_SHARE_OF_FLOOR_BPS = 2000` caps the share at 20 bps (0.20%). `P` is computed from the hardcoded `FLOOR` constant, not the factory's live `floorTaxBps()`. - **`C = 50 + (T - FLOOR) / 2`** when reflections are on, **`0`** when off — the holders' share. - **`H = R - P - C`** — the handle's pot, the remainder of the splitter's inflow. Constraints enforced on chain: `T >= factory.floorTaxBps()` (default 100) else `TaxBelowFloor`; `T <= PonsV2LaunchFactory.maxCreatorTaxBps()` else `TaxAboveMax` (the shared constant `MAX_CREATOR_TAX_BPS` is 1000, i.e. 10%, but the live Pons ceiling is re-read at launch); when reflections are on, `T % 50 == 0` else `TaxNotOnGrid`. The first launch pins `T` and the reflections flag forever (`TaxPinned` / `ReflectionsPinned` on any later launch at a different value). **Split table** (bps of volume; `%` in parentheses). `Pons protocol` keeps 0.30% (30 bps) of volume on top of `R` and never reaches the splitter, so a trader's total tax is `T + 1.00%`. | Creator tax `T` | Reflections | `R` (splitter) | `P` (platform) | `C` (holders) | `H` (pot) | |---|---|---|---|---|---| | 1% (100) | on | 1.70% | 0.15% | 0.50% | 1.05% | | 1% (100) | off | 1.70% | 0.15% | 0% | 1.55% | | 2% (200) | on | 2.70% | 0.15% | 1.00% | 1.55% | | 2% (200) | off | 2.70% | 0.15% | 0% | 2.55% | | 5% (500) | on | 5.70% | 0.15% | 2.50% | 3.05% | | 5% (500) | off | 5.70% | 0.15% | 0% | 5.55% | | 10% (1000) | on | 10.70% | 0.15% | 5.00% | 5.55% | | 10% (1000) | off | 10.70% | 0.15% | 0% | 10.55% | ### Delivery fee `deliveryFeeBps` defaults to **300 (3%)** and is capped at `MAX_DELIVERY_FEE_BPS = 500` (5%). It is owner-settable instantly (no timelock), but the fee is **snapshotted per request**, so a later change can never re-price a delivery already in flight: `executeDelivery` computes the fee from `d.feeBps`, not a live read. The fee is paid to `factory.treasury()`; the remainder goes to the snapshotted `deliveryWallet`. Read `deliveryFeeBps()` live before trusting the number. ### Delivery delay `deliveryDelay` defaults to **86 400 seconds (24 hours)** — the owner window during which the handle owner can `CLAIM` (which cancels the request) or cancel before an operator can execute. It is owner-settable instantly, bounded by `MIN_DELIVERY_DELAY = 3600` and `MAX_DELIVERY_DELAY = 7 days`, and snapshotted into the request's `eta` at request time. Read `deliveryDelay()` live. ### Launch fee The launcher pays Pons's exact live `launchFee()`: the vault reads it, requires `msg.value >= launchFee()` (`InsufficientLaunchValue`), forwards exactly that amount through `FeeSplitter.launch` to `PonsV2LaunchFactory.launchToken`, and refunds the excess to the launcher (`EthTransferFailed` if the refund call fails). X Payday takes no launch fee of its own. ### Cost of sponsoring a launch A wallet launch costs the launcher the Pons `launchFee()` only. A **sponsored** launch moves that cost to X Payday's keeper: - **0.0005 ETH per launch** at today's Pons fee (`launchFee() = 500000000000000` wei; the contract reads it live, so re-read it before quoting a number — `research/pons.md`). - The keeper must hold enough ETH to cover the launch fee **and** the gas of the `createVaultAndLaunch` transaction it submits. Fund the keeper well above the per-launch cost; `ops/DEPLOY.md` §0.4 recommends 0.05 ETH and §8 alerts when its balance drops below **0.01 ETH**. - The global `SPONSORED_LAUNCHES_PER_DAY` cap (default 50) bounds the daily spend; at today's fee that is at most 0.025 ETH/day of launch fees, plus gas, across all users. - `SPONSORED_LAUNCHES_PER_DAY` and `SPONSORED_LAUNCH_ENABLED` are read by the API (`apps/api/src/env.ts`); `GET /status` reports the keeper address so its balance can be watched. ## Reflections: trustless, but you must stake For a reflections-on coin, `C` bps of every `collect()` inflow is transferred to that coin's `StakingRewards` clone (`contracts/src/StakingRewards.sol`) via `notifyRewardAmount`, which streams it to stakers as the pair token (USDG) over a **7-day** window (`rewardsDuration = 7 days`). It is "trustless" in the specific sense that the maths is on chain and **no keeper chooses who gets what**: there is no allocation step, no Merkle root and no privileged distributor. Everyone who stakes shares the stream in proportion to `weight = balance * tierMultiplier / 10` at 1e18 precision, and any leftover from an unfinished window is rolled into the next window rather than dropped. Lock tiers (longer locks take a proportionally larger share of the same stream): | Tier | Lock | Multiplier | Weight factor | |---|---|---|---| | 0 | none | 1.0× | `amount * 10 / 10` | | 1 | 30 days | 1.5× | `amount * 15 / 10` | | 2 | 90 days | 2.0× | `amount * 20 / 10` | `stake(amount, tier)` re-weights the whole position at the new tier and pushes `unlockAt` to the later of the existing lock and `now + tierLockSeconds(tier)`; a lower tier while still locked reverts `TierDowngrade`. `withdraw(amount)` reverts `Locked` before `unlockAt`; `getReward()` pays accrued USDG; `exit()` does both. `notifyRewardAmount` is callable only by the coin's splitter or its owning vault (the two notifiers), and the contract refuses a schedule whose implied rate exceeds its own reward-token balance (`RewardRateTooHigh`). **The limits, stated plainly:** reflections only reach *stakers*. A holder who simply holds the coin earns nothing from the stream; `C` still leaves the pot, but it accrues to the staking pool, and unclaimed rewards sit in the contract until a staker calls `getReward()`/`exit()`. If a reflections-on coin's staking pool is empty, the stream accrues to nobody in practice (the balance is held by the pool contract). Reflections are a per-launch toggle pinned forever at the first launch. ## Auto-pay and the 24-hour activation delay Auto-pay is a persistent `payoutAddress` on the vault, set through an owner `SET_PAYOUT` attestation (see hop 8). It is deliberately delayed: proposing a non-zero address sets `pendingPayoutAddress` and `pendingPayoutActivatesAt = now + 24h`, and only `activatePayout()` after that time makes it live. Anyone may call `activatePayout()`. Clearing auto-pay (`to == 0`) takes effect immediately. The delay exists so a single compromised signing moment cannot silently redirect the pot: the owner (and anyone watching) gets a 24-hour public window, with the proposal visible on chain as `PayoutAddressProposed`, to notice and cancel by sending `SET_PAYOUT` to zero. Once active, `payout()` is permissionless and pushes the entire pot to the address; the keeper calls it on every tick (`apps/api/src/keeper/tick.ts`). Setting a payout address also cancels any active delivery request, and vice versa: `requestDelivery()` reverts `PayoutAddressConfigured` while auto-pay is on. ## Handle lock The handle owner can lock the handle through a `LAUNCH_LOCK` attestation (`amount == 1`) and unlock it (`amount == 0`); the vault emits `LaunchLockSet(bool)` and any later `launch()` reverts `LaunchLocked`. The API also refuses to issue a launch ticket for a locked handle (`409 LAUNCH_LOCKED`, `POST /launch/ticket`). A locked handle can still claim, set auto-pay, be delivered to, or be swept as abandoned — the lock only stops *new coins* for that handle. ## Delivery to X Money The X Payday-native exit is the on-chain claim (`CLAIM` to any wallet, no fee). Delivery to X Money is the community option, and it comes with real caveats: **The owner's one-click path (`/claim`).** For a signed-in handle owner the claim page offers **Send my pot to X Money** in one click. It calls the same permissionless `POST /delivery/request { vault }` (`apps/api/src/keeper/routes.ts`), which enqueues a `requestDelivery` relay job — nothing extra or privileged happens on chain. The copy at the button states plainly that X Payday keeps a **3% fee** and that the send happens after a **24-hour wait**, during which the owner can still claim the pot, and that X Payday's team completes the X Money send and records a public proof (the ops step below). The request is rate-limited to 5 per hour per IP. An owner whose eligibility hint is not `us` sees **Claim to a wallet** only, with an explainer on getting a wallet (the Robinhood app → a Robinhood Chain wallet); X Payday does not promise an X Money send it cannot make. Delivery is never required: claiming to a wallet is free. For everyone else the same request is permissionless, with these caveats: 1. **Permissionless request.** Anyone may call `HandleVault.requestDelivery()` while the pot is non-empty and auto-pay is off. The request snapshots `feeBps`, `wallet` and `eta = now + deliveryDelay()`. 2. **Owner window.** During the delay (default 24h) the handle owner can claim the pot with `CLAIM`, or cancel the delivery with `CANCEL_DELIVERY` (also cancels a request incidentally). The factory guardian can `cancelDelivery()` at any time as an incident brake. Pausing the protocol blocks new `requestDelivery`/`executeDelivery` but never a claim. 3. **Operator execution.** After `eta`, only `factory.deliveryOperator()` — a gas-only key that can never choose a destination — may call `HandleVault.executeDelivery()`. It pays the snapshotted fee to the treasury and the remainder to the snapshotted `deliveryWallet`. 4. **Custody hop.** The remaining USDG lands in `deliveryWallet`, an X Payday-controlled address, before continuing on to the recipient. That is a real, named custody hop, not a hidden one. 5. **The human last mile.** A person sends the X Money peer-to-peer payment to the handle owner from X Payday's own X account. There is no X Money API and no automated fiat rail in this build; the send is manual by design. 6. **Public proof.** The operator records the send in the ops console (`POST /ops/deliveries/:id/sent`), which marks the delivery `sent` and writes a `delivery_sent` activity event with the proof URL and note. Nothing is announced as delivered before it is marked sent. 7. **Eligibility hint.** The web app may hide or disable the delivery button for handles whose X "account based in" panel says they are not in the United States, because X Money is a US product. This hint is **unofficial**, derived from fxtwitter's mirror of that panel when no X API bearer is configured (`apps/api/src/handles/types.ts`, `eligibilityFromBasedIn`); only an exact "United States" is `us`, any other value is `non_us`, and a missing value is `unknown` (never assumed non-US). The gate only affects whether the delivery button is offered; it never affects launching, claiming or reflections. 8. **Honest regulatory note.** Converting and sending an unaffiliated handle's own accrued funds to them, for a fee, can be money transmission under US rules (31 CFR 1010.100(ff)(5)), and nothing here is legal clearance for that activity. X Payday's launch/claim legs are on-chain transfers to addresses the owner chose; the delivery leg is the one with the open question, and it is stated here rather than buried. ## Claim to Robinhood (custodial) and Robinhood Wallet (self-custody) Two Robinhood-related paths. Neither changes a contract or a fee rule: the payout is the same `CLAIM` attestation and auto-pay is the same `SET_PAYOUT` described under "The end-to-end flow". The only thing here is where the owner points them. ### Claim to a Robinhood USDG deposit address (custodial) The signed-in owner's `/claim` page (`apps/web/app/claim/claim-panel.tsx`) offers **Get paid in your Robinhood app** (the card is `apps/web/components/claim/robinhood-claim.tsx`): paste the USDG deposit address of a Robinhood Crypto account and send a claim. The vault then transfers to that address exactly as it would to any wallet. - **No fee.** A claim costs nothing from X Payday; the **3%** fee belongs only to the X Money delivery service above. There is no bridge and no third-party payment app. - **The exact amounts.** `CLAIM` carries an `amount` in USDG base units (a decimal string); `amount = 0` pays the whole `available()`. The card's **Send $1 test** button sends `1000000` base units = **$1.00** (USDG has 6 decimals); **Send my whole pot** sends `0`. - **Auto-pay.** The toggle **Also turn on auto-pay to this address** runs `SET_PAYOUT` to the same address after the test claim confirms, so future fees are pushed without another sign-in. It is the standard 24-hour activation notice (propose → `activatePayout()` after `eta` → permissionless `payout()`), nothing new on chain. - **Official Robinhood facts** (read the sources; they can change): - Every Robinhood Crypto account has a unique USDG deposit address. USDG deposits are supported on **Robinhood Chain**, and also on Ethereum and Solana — **the sender must pick Robinhood Chain**, and a deposit sent on the wrong network is not credited and can be lost. - A USDG deposit needs **30 confirmations** (about **5 minutes**) and is then subject to a compliance review that "can take a few hours". There is **no minimum**. - USDG deposits are **not available to New York residents** (USDG is not tradable in NY or TX). - Sources: , . - **UNVERIFIED: contract-origin deposits.** The pot is a contract (`HandleVault`) sending USDG, not a personal wallet, and Robinhood's docs describe wallet deposits. Whether a deposit that **originates from a contract** is credited normally is **not verified** — treat it as unknown until tested. That is why the UI offers the **$1 test** first: send `1000000` base units, confirm it shows up in the Robinhood app, and only then claim the rest or turn on auto-pay. If the $1 test is not credited, do not send more; use a plain wallet or the X Money delivery above. - **Not a partner.** Robinhood is not a partner and X Payday is not affiliated with Robinhood. A claim is a normal on-chain transfer to an address the owner names; whether and when Robinhood credits it is Robinhood's decision and outside our control. ### Robinhood Wallet (self-custody) Robinhood Wallet is Robinhood's self-custody app. It supports Robinhood Chain (chain 4663) natively and connects to dapps with WalletConnect, so a buyer or launcher can use it without bridging: - `apps/web` (`apps/web/app/launch/`, `apps/web/lib/contracts.ts`, RainbowKit + wagmi) targets Robinhood Chain; connect Robinhood Wallet — natively or through WalletConnect — to `/launch` to launch a coin, or use it to buy on the Pons site (the launch factory and curve addresses are under "Links"/"Contracts and addresses"). - Buying needs USDG on Robinhood Chain plus a little ETH for gas. The app never custodies the wallet or its keys. - Source: . ### Where it sits relative to X Money delivery A Robinhood claim is the **primary** no-bridge payout: `CLAIM` to a custodial deposit address, 0% fee, and no delay — a claim is immediate, and claiming also cancels any pending delivery. The **X Money delivery** above remains the fallback for US handles without a Robinhood account, and it is the path that charges 3% and waits 24 hours. Both end at an address the owner names; a Robinhood deposit address is simply another `to` in the same `CLAIM`. ## Abandoned pots go to holders, never to the treasury `HandleVault.sweepAbandoned()` is permissionless. It requires **180 days** (`ABANDONMENT_PERIOD`) since `lastActivity`, no `payoutAddress`, no active delivery, a non-zero `available()`, and at least one coin with a holders' pool (`staking != 0`). It transfers the entire pot to the **most recently launched** coin's `StakingRewards` pool via `notifyRewardAmount`, and emits `AbandonedSwept(staking, amount)`. There is no function anywhere in the vault that pays an abandoned pot to the treasury or to any admin-chosen address; `NotAbandoned` / `NoHoldersPool` guard the path. `lastActivity` is bumped on initialize, launch, `claimFees` (when the pot grew), `execute`, `payout` and `requestDelivery`. Two consequences worth stating: a pot whose coins were all launched with reflections **off** has no staking pool, so `sweepAbandoned()` reverts `NoHoldersPool` and the funds simply stay in the vault (they are not burnable and not sweepable to the treasury); and any activity at all — a claim, a fee collection, a delivery request — resets the 180-day clock. ## Trust model **The oracle is the single trust root.** An oracle-signed EIP-712 `Attestation` is the only on-chain authorization for an owner action, and `CLAIM` pays the whole pot to the `to` address the attestation names, immediately. The oracle also signs `LaunchTicket`s. There is no owner-only key on a vault and no contest window between signature and payout. A compromised oracle key, for as long as it went unrevoked, could sign a `CLAIM` to an attacker's address for any launched pot with a balance, sign `SET_PAYOUT` to redirect future fees, sign tickets to create vaults for arbitrary handles, and lock handles. That is the honest residual risk. In this build the oracle key is read from `ORACLE_PRIVATE_KEY` in the API process (`apps/api/src/lib/clients.ts`), so production isolation of that key (a separate signer service, an HSM or a policy-constrained worker) is a deployment requirement, not an implementation detail. The mitigations that exist on chain are: `oracleEpoch` (every signature pins the epoch, so a guardian `revokeOracle()` invalidates all of them at once), short deadlines (tickets ≤ 1h, attestations ≤ 1h, issued 10/30 minutes out by the API), single-use nonces (`claimNonce`, `ticketUsed`), and the 24-hour payout-activation delay. **The guardian can stop things but cannot move funds.** `revokeOracle()` zeroes the oracle and increments `oracleEpoch` in one transaction (instant, deliberately asymmetric against the 7-day-timelocked appointment of a replacement). `setPaused(true)` blocks vault creation, launches, `execute`, `requestDelivery` and `executeDelivery`; it never blocks `collect`, `claimFees`, `payout` to an already-set address, or staking withdrawals/rewards. The guardian may also cancel any active delivery. None of these can send a pot anywhere. **Timelocks.** Changes to the treasury, delivery wallet, floor tax, platform share and oracle sit behind a 7-day `TIMELOCK` (propose → execute after eta → optional cancel), each with its own `proposeX`/`executeX`/`cancelX`/`pendingX`/`pendingXEta` getters. Published on chain in advance, they affect only vaults that have not yet pinned their split. The instant, owner-only settings are `setDeliveryFeeBps`, `setDeliveryDelay`, `setDeliveryOperator` and `setGuardian`; delivery fee and delay are safe to change instantly because requests snapshot them. **Six distinct roles, enforced at deploy.** `HandleVaultFactory`'s constructor takes a `FactoryConfig { owner, guardian, oracle, treasury, deliveryWallet, deliveryOperator, ponsFactory, ponsEscrow, pairToken }` and reverts `RolesNotDistinct` unless the first six addresses are pairwise distinct and non-zero. A single key holding several roles defeats the separation. The six live roles (listed under "Contracts and addresses") are distinct, but **`owner` is a single EOA today** — the deployer key `0x0679626184519f7fCfbC12e8aA49f83219f7C176` — so a Safe multisig for `owner` (and ideally distinct keys for `guardian`/`oracle`/`treasury`/`deliveryWallet`/`deliveryOperator`) is recommended, and rotating the owner to a Safe is planned. Safe is deployed on Robinhood Chain: Safe v1.4.1 singleton `0x41675C099F32341bf84BFc5382aF534df5C7461a`, SafeL2 v1.4.1 singleton `0x29fcB43b46531BcA003ddC8FCB67FFE91900C762`, SafeProxyFactory v1.4.1 `0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC` (`research/chain-and-data.md` §4; byte-for-byte identical to Ethereum mainnet). A hosted Safe Transaction Service for chain 4663 is not confirmed, so plan on a custom RPC. **What the delivery operator can and cannot do.** `executeDelivery()` is callable only by `factory.deliveryOperator()`. It can move a *requested* vault's balance only to the wallet and fee snapshotted at request time, and only after `eta`; it cannot choose a destination, cannot execute without a prior request, and cannot pay the treasury anything other than the snapshotted fee. **The launcher and the owner are not privileged.** Any wallet can launch once it holds a valid ticket; the ticket only proves the oracle authorized that `(handle, xUserId, launcher)`. The vault's owner identity is not stored on chain; it is proven fresh at each `execute` by the oracle attestation, which the API only signs for a session that passed X OAuth. ## Contracts and addresses | Contract | Source | Address | |---|---|---| | `HandleVaultFactory` | `contracts/src/HandleVaultFactory.sol` | [`0xbFd7945e9Dd1C13eD4B5cDAb133a0fCc4F0c3032`](https://hoodscan.co/address/0xbFd7945e9Dd1C13eD4B5cDAb133a0fCc4F0c3032) (deploy block 62414357) | | `HandleVault` implementation | `contracts/src/HandleVault.sol` | [`0x46315720Ffb2c60233768161ca01A998A807aa83`](https://hoodscan.co/address/0x46315720Ffb2c60233768161ca01A998A807aa83) | | `FeeSplitter` implementation | `contracts/src/FeeSplitter.sol` | [`0xAc095dc1eE5Ba2cd80a9A58E95ca39614B49a645`](https://hoodscan.co/address/0xAc095dc1eE5Ba2cd80a9A58E95ca39614B49a645) | | `StakingRewards` implementation | `contracts/src/StakingRewards.sol` | [`0x8Ba654f8545bF3786e13d6a628bd227b08Afd649`](https://hoodscan.co/address/0x8Ba654f8545bF3786e13d6a628bd227b08Afd649) | | Local `HandleVaultFactory` (anvil fork, not public) | `contracts/deployments/local.json` | `0x8ac87219a0F5639BC01b470F87BA2b26356CB2B9` | | USDG (pair token) | `packages/shared/src/chain.ts` | `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` | | Pons V2 launch factory | `packages/shared/src/chain.ts` | `0x7eD598BcEf8bd9Edd8C97A195C6d13f40801EC7e` | | Pons V2 fee escrow | `packages/shared/src/chain.ts` | `0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9e` | | Pons V2 meme hook | `packages/shared/src/chain.ts` | `0xE5e702641Ea86F4ae6cC3cDaeD2B886f976Be044` | The table above is the live deployment: factory, implementations and configuration are on chain 4663 today, Sourcify exact-match verified, and match `contracts/deployments/4663.json`. Live reads: `floorTaxBps() = 100`, `platformShareOfFloorBps() = 1500`, `deliveryFeeBps() = 300`, `deliveryDelay() = 86400`, `oracleEpoch() = 1`; the pair token is USDG and `ponsFactory()` is the Pons V2 launch factory (`0x7eD598BcEf8bd9Edd8C97A195C6d13f40801EC7e`). The six roles are `owner()` `0x0679626184519f7fCfbC12e8aA49f83219f7C176` (also the deployer), `guardian()` `0xcfc7bd4Dde83bCE1C8840e3cAa3351F012DaF01C`, `oracle()` `0x914e42FaD9fDC780258b3b91803d97A6DB53154d`, `treasury()` `0x5122C7a24a2e89273050e35546A74B885945fBBE`, `deliveryWallet()` `0x50F85CCA7e962a3057bba6157dB042E69A7BD201` and `deliveryOperator()` `0xE79C02c3Eba0623bd7922a2ea64d99ca7b7D2235` (the keeper). **Honest note:** `owner` is a single EOA today, so the role separation described under "Trust model" is not yet complete; rotating it to a Safe multisig is planned. The deploy scripts write exactly which addresses are live: - Production: `contracts/script/Deploy.s.sol` reads `OWNER, GUARDIAN, ORACLE, TREASURY, DELIVERY_WALLET, DELIVERY_OPERATOR` from the environment (never from the broadcaster), deploys the three implementations and the factory, writes `contracts/deployments/.json` `{ chainId, factory, vaultImplementation, splitterImplementation, stakingImplementation, deployBlock, txHashes }`, and prints Sourcify verify commands. Runbook: `ops/DEPLOY.md`. - Local: `contracts/script/DeployLocal.s.sol` uses anvil accounts #0–#5 as the six roles and writes `contracts/deployments/local.json` plus `contracts/deployments/local.env` (`FACTORY_ADDRESS`, `FACTORY_DEPLOY_BLOCK`, `ORACLE_PRIVATE_KEY` = anvil #2, `KEEPER_PRIVATE_KEY` = anvil #5, `DELIVERY_OPERATOR` = anvil #5). ### Reading the live configuration Every number below is a getter on the factory. With `F` set to the deployed factory address and `RPC` to an RPC endpoint: ```bash cast call $F "floorTaxBps()(uint16)" --rpc-url $RPC # 100 (1%) cast call $F "platformShareOfFloorBps()(uint16)" --rpc-url $RPC # 1500 -> P = 15 bps cast call $F "deliveryFeeBps()(uint16)" --rpc-url $RPC # 300 (3%), cap 500 cast call $F "deliveryDelay()(uint32)" --rpc-url $RPC # 86400 (24h) cast call $F "owner()(address)" --rpc-url $RPC # 0x0679… (EOA today; Safe rotation planned) cast call $F "oracle()(address)" --rpc-url $RPC # 0x914e… cast call $F "oracleEpoch()(uint64)" --rpc-url $RPC # 1 cast call $F "guardian()(address)" --rpc-url $RPC # 0xcfc7… cast call $F "treasury()(address)" --rpc-url $RPC # 0x5122… cast call $F "deliveryWallet()(address)" --rpc-url $RPC # 0x50F8… cast call $F "deliveryOperator()(address)" --rpc-url $RPC # 0xE79C… (keeper) cast call $F "pairToken()(address)" --rpc-url $RPC # USDG 0x5fc5… cast call $F "ponsFactory()(address)" --rpc-url $RPC # 0x7eD5… cast call $F "paused()(bool)" --rpc-url $RPC cat contracts/deployments/4663.json # the authoritative address record ``` ## Public API `@pot/api` (`apps/api/`) is a Bun + Hono service on port 8787, with a `bun:sqlite` store filled by the indexer. All responses are JSON; errors are `{ error: { code, message } }`. CORS allows `WEB_ORIGIN` only. USDG amounts are decimal strings of base units (6 decimals); `*_usd`/`_usd` fields are plain numbers. ### Public | Endpoint | Response | |---|---| | `GET /status` | `StatusDTO`: `{ ok, chainId, factory, indexedBlock, headBlock, lagBlocks, counts: { vaults, coins, claims, deliveries }, totals: { potsUsdg, feesToPotsUsdg, feesToHoldersUsdg, claimedUsdg, paidOutUsdg, deliveredUsdg }, keeper: { enabled, address, lastRunAt }, deliveryFeeBps, deliveryDelay, platformBps }`. `deliveryFeeBps`/`deliveryDelay` are read live from the factory (falling back to `300`/`86400` with no factory). | | `GET /vaults?limit&cursor` | `{ items: VaultDTO[], nextCursor }` | | `GET /vaults/:address` | `VaultDTO & { coins: CoinDTO[], recent: EventDTO[] }`; 400 `INVALID_ADDRESS`, 404 `VAULT_NOT_FOUND` | | `GET /pots?sort=balance\|fees\|volume&limit` | `{ items: PotDTO[] }`; bad sort → 400 `INVALID_SORT` | | `GET /coins?sort=new\|volume\|mcap&limit&cursor` | `{ items: CoinDTO[], nextCursor }` | | `GET /activity?cursor&kind&handle&limit` | `{ items: EventDTO[], nextCursor }`; unknown kind → 400 `INVALID_KIND` | | `GET /deliveries?status&vault&limit` | `{ items: DeliveryDTO[] }`; status ∈ `requested\|executed\|cancelled\|sent` | | `GET /prices/:token` | `PriceDTO`; 404 `PRICE_NOT_FOUND` | | `GET /h/:handle/summary` | `HandleSummaryDTO = { handle: HandleDTO, vault: VaultDTO\|null, coins: CoinDTO[], activity: EventDTO[], delivery: DeliveryDTO\|null }`; 404 `HANDLE_NOT_FOUND` | | `GET /handles/:handle` | `HandleDTO`; 404 `HANDLE_NOT_FOUND` | `VaultDTO = { address, xUserId, handle, handleHash, createdBlock, createdTx, creator, payoutAddress, launchLocked, lastActivity, balance, pendingFees, coinCount? }`. `CoinDTO = { token, vault, coinIndex, curve, splitter, staking, creatorTaxBps, reflections, name, symbol, launcher, launchedBlock, launchedTx, launchedAt, priceUsd, mcapUsd, volume24h, handle?, ponsUrl?, dexUrl? }`. `EventDTO = { id, kind, ts, block, tx, txUrl, handle, vault, token, symbol, actor, amount, amount2, amount3, data }`; `kind` ∈ `vault_created, launched, fees_claimed, claimed, payout_proposed, payout_set, paid_out, launch_lock, delivery_requested, delivery_executed, delivery_cancelled, delivery_sent, abandoned_swept, staked, withdrawn, reward_paid, oracle_set, oracle_revoked`. `PotDTO = { rank, handle, xUserId, vault, balance, feesTotal, volume24h, coinCount, claimedTotal, heat }` with `heat` ∈ `inferno|fire|hot|warm|cold`. `DeliveryDTO = { id, vault, requestId, requester, eta, feeBps, wallet, status, amount, fee, executedTx, sentProofUrl, sentNote, sentAt, handle? }`. `HandleDTO = { handle, xUserId, name, avatarUrl, verified, followers, eligibility, vault, predictedVault, launchLocked, source }` with `source` ∈ `x_api|fxtwitter|cache|dev`. (The full TS definitions live in `packages/shared/src/types.ts`.) ### Auth (X OAuth 2.0 PKCE), `apps/api/src/auth/routes.ts` | Endpoint | Response | |---|---| | `GET /auth/x/login?return_to=/path` | 302 to X authorize (PKCE S256; state in a short-lived signed cookie) | | `GET /auth/x/callback` | Exchanges the code, reads `users/me`, sets the HMAC-signed `sid` cookie, 302 to `WEB_ORIGIN + return_to` | | `GET /auth/me` | `{ user: { xUserId, handle, name, avatarUrl } \| null }` | | `POST /auth/logout` | Clears the session | | `GET /auth/dev/login?handle=&xUserId=&return_to=` | Only when `DEV_FAKE_X=1`; signs in without X (local/e2e) | ### Signer and owner actions | Endpoint | Auth | Response | |---|---|---| | `GET /launch/challenge?address=` | none | `{ message, nonce, expiresAt }` (EIP-191 text: domain, address, nonce, expiry); 400 `INVALID_ADDRESS` | | `POST /launch/ticket` `{ handle, address, nonce, signature, xUserId? }` | none | `{ ticket: { handleHash, xUserId, launcher, deadline, oracleEpoch, nonce }, signature, handle, xUserId, vault, predictedVault, launchLocked }`. Errors: `CHALLENGE_NOT_FOUND`/`CHALLENGE_USED`/`CHALLENGE_EXPIRED`, `ADDRESS_MISMATCH`, `INVALID_SIGNATURE`, `HANDLE_NOT_FOUND`, `X_USER_ID_MISMATCH`, `409 LAUNCH_LOCKED`, `FACTORY_UNAVAILABLE`, `ORACLE_UNAVAILABLE` | | `POST /launch/sponsored` `{ handle, name, symbol, metadataURI?, creatorTaxBps?, reflections? }` | session (X) | `202 { jobId }` — the wallet-free launch (`apps/api/src/signer/sponsored.ts`). Issues the same `LaunchTicket` with `launcher` = the **keeper address** and enqueues a `sponsoredLaunch` relay job; the keeper pays the Pons `launchFee()` and submits `createVaultAndLaunch` (no vault yet) or `HandleVault.launch` (existing vault). Defaults `creatorTaxBps=200`, `reflections=true`; when reflections are on, `creatorTaxBps` must be a multiple of 50. Caps: 1 per `xUserId`/24h and `SPONSORED_LAUNCHES_PER_DAY` globally; `SPONSORED_LAUNCH_ENABLED=0` closes the path. Errors: `401 UNAUTHORIZED`, `400 INVALID_BODY`/`INVALID_HANDLE`, `404 HANDLE_NOT_FOUND`, `409 LAUNCH_LOCKED`, `429 SPONSORED_LIMIT`, `503 SPONSORED_DISABLED`/`QUEUE_FULL`/`KEEPER_UNAVAILABLE`/`ORACLE_UNAVAILABLE`/`FACTORY_UNAVAILABLE`/`CHAIN_UNAVAILABLE` | | `POST /claim/attest` `{ vault, action: 'CLAIM'\|'SET_PAYOUT'\|'LAUNCH_LOCK'\|'CANCEL_DELIVERY', to?, amount? }` | session (X) | `{ vault, attestation: { handleHash, xUserId, to, action, amount, deadline, nonce, oracleEpoch }, signature }`. Verifies the session's `xUserId` owns the vault (else 403 `NOT_VAULT_OWNER`); `CLAIM`/`SET_PAYOUT` require `to`; `LAUNCH_LOCK` requires `amount` `"0"` or `"1"`; deadline 30 min out; `claimNonce`/`oracleEpoch` read live | | `GET /me/pots` | session (X) | `MyPotsDTO = { vault, predictedVault, coins, deliveries, claimable }`; `401 UNAUTHORIZED` without a session | ### Relay, delivery and keeper | Endpoint | Auth | Response | |---|---|---| | `POST /claim/submit` `{ vault, attestation, signature }` | none | `202 { jobId }` (enqueues an `execute` relay job the keeper submits) | | `GET /relay/:jobId` | none | `{ jobId, status, tx, error, result }`; `status` ∈ `queued\|sent\|confirmed\|failed`. `result` is `null` except for a confirmed `sponsoredLaunch`, when it is `{ vault, token, curve, tx }` parsed from the receipt's `Launched` event | | `POST /delivery/request` `{ vault }` | none, rate-limited 5/hour per IP | `202 { jobId }`; `429 RATE_LIMITED`; 404 `VAULT_NOT_FOUND` | | `GET /ops/deliveries` | `X-Ops-Token` | `{ items: DeliveryDTO[] }` — executed but not yet sent | | `POST /ops/deliveries/:id/sent` `{ proofUrl, note? }` | `X-Ops-Token` | The updated `DeliveryDTO`; marks `sent` and writes a `delivery_sent` event | | `GET /ops/relay` | `X-Ops-Token` | `{ items: [{ id, kind, payload, status, tx, error, createdAt, updatedAt }] }` | | `POST /ops/keeper/run` | `X-Ops-Token` | `{ ok: true, ...tickResult }` — runs one keeper tick now | ### Background loops (`apps/api/src/index.ts`) - **Indexer** (`src/indexer/`): polls `getLogs` for the factory, known vaults, splitters and staking pools from `FACTORY_DEPLOY_BLOCK` in ≤2000-block chunks every 5 s, decodes with the shared ABIs, upserts the tables and keeps the cursor in `cursors('indexer')`. After each chunk it refreshes `vaults.balance` (`available()`), `pending_fees` (`pendingFees()`) and `payout_address`. - **Keeper** (`src/keeper/`): every 30 s: submit queued relay jobs (`execute` / `requestDelivery` / `sponsoredLaunch`); call `claimFees()` on vaults with at least 1 USDG of `pendingFees`; call `activatePayout()` when due; call `payout()` where `payout_address != 0 && balance > 0`; call `executeDelivery()` for due deliveries (only when the keeper address equals `deliveryOperator`). It never throws out of a tick. A `sponsoredLaunch` job is drained by `sponsoredLaunch` in `src/keeper/chain.ts`, which reads the live Pons `launchFee()` and sends it as `value`; the confirmed job's `result` carries the new `vault`/`token`/`curve`. - **Prices** (`src/prices/`): Dexscreener per token every 60 s, chain slug `robinhood`. ## Known limitations - **The X Money last mile is manual.** There is no X Money API and no automated fiat rail in this build; a human sends the payment from `@xpayday` and records a proof. Everything up to `deliveryWallet` is automated; everything after it is a human tap. - **Handle resolution falls back to fxtwitter.** With no `X_BEARER_TOKEN`, profiles and the numeric `xUserId` come from fxtwitter, an unofficial mirror of X. A resolve failure means no ticket can be issued for that handle. - **The eligibility hint is unofficial.** `eligibility` (`us`/`non_us`/`unknown`) is derived from X's "account based in" panel via fxtwitter; it gates only whether the delivery button is offered, never the launch or claim paths, and it can be wrong. - **Sponsored launches spend X Payday's ETH, not the user's.** Each `POST /launch/sponsored` costs the keeper the live Pons `launchFee()` (0.0005 ETH at today's fee) plus gas, so the path is capped (one per `xUserId` per 24 h; `SPONSORED_LAUNCHES_PER_DAY` default 50) and can be closed with `SPONSORED_LAUNCH_ENABLED=0`. Over a cap the API answers `429 SPONSORED_LIMIT`; if the keeper is unfunded the relay job fails with the send error. Fund the keeper and alert below 0.01 ETH (`ops/DEPLOY.md` §8). - **The wallet-free paths do not cover everything.** Launching a coin for a handle, claiming to a Robinhood USDG deposit address, and requesting X Money delivery work without a wallet; buying the coin, claiming to a self-custody address you control, and setting auto-pay still need a wallet (labelled **Advanced**) — Robinhood Wallet supports chain 4663 natively. A sponsored launch only launches — it does not buy any of the coin. - **A Robinhood deposit is Robinhood's credit decision, and contract-origin deposits are unverified.** Claiming to a Robinhood USDG deposit address is a normal on-chain transfer, but whether Robinhood credits a deposit that originates **from a contract** is not verified, which is why the UI sends a **$1 test** first. Deposits are not available to NY residents, need 30 confirmations plus a compliance review, and must be sent on Robinhood Chain. Robinhood is not a partner and X Payday is not affiliated with it; its docs can change. See "Claim to Robinhood (custodial) and Robinhood Wallet (self-custody)". - **A delivery can be requested by anyone.** `POST /delivery/request` is permissionless (rate-limited 5/hour/IP); the `/claim` button is a convenience, not a new on-chain privilege. The 24-hour owner window (claim cancels the request) and the guardian's `cancelDelivery()` are the protections, as described under "Delivery to X Money". - **Reflections require staking.** Holders who do not stake earn nothing from the holders' stream; `C` still leaves the pot into the pool contract. - **Oracle centralization.** The oracle key is the single trust root (see "Trust model"). The code reads it from `ORACLE_PRIVATE_KEY` in the API process today; isolating it (separate signer, HSM or policy-constrained worker) is required before production. - **Delivery is a real custody hop and an open regulatory question.** Funds pass through `deliveryWallet`, and money-transmission rules may apply to the delivery service. This is not legal clearance. - **Delivery fee and delay are instantly owner-settable.** They are bounded on chain (`MAX_DELIVERY_FEE_BPS = 500`, `MIN/MAX_DELIVERY_DELAY`) and snapshotted per request, but read them live. - **Abandoned pots with no reflections-on coin cannot be swept.** `sweepAbandoned()` reverts `NoHoldersPool` when every coin has reflections off, so such funds stay in the vault. - **No factory admin UI.** Owner/guardian actions are transactions (and `cast` commands); the repo ships no privileged dashboard. There is no platform token in this build. ## How to verify each claim Every command below reads public chain state or a public endpoint; none requires a secret. Set `F` to the factory from `contracts/deployments/4663.json` and `RPC` to an RPC endpoint first: ```bash F=$(jq -r .factory contracts/deployments/4663.json) RPC=https://rpc.mainnet.chain.robinhood.com # public; or your own provider API=http://localhost:8787 # a running @pot/api ``` ```bash # 1. The factory's pinned configuration and roles (see "Reading the live configuration"). cast call $F "floorTaxBps()(uint16)" --rpc-url $RPC # 100 cast call $F "platformShareOfFloorBps()(uint16)" --rpc-url $RPC # 1500 cast call $F "deliveryFeeBps()(uint16)" --rpc-url $RPC # 300 cast call $F "deliveryDelay()(uint32)" --rpc-url $RPC # 86400 cast call $F "oracle()(address)" --rpc-url $RPC cast call $F "guardian()(address)" --rpc-url $RPC cast call $F "treasury()(address)" --rpc-url $RPC cast call $F "deliveryWallet()(address)" --rpc-url $RPC cast call $F "deliveryOperator()(address)" --rpc-url $RPC # 2. A vault is keyed by xUserId and its address is predictable before it exists. cast call $F "vaultOf(uint256)(address)" --rpc-url $RPC cast call $F "predictVault(uint256)(address)" --rpc-url $RPC cast call $F "vaultCount()(uint256)" --rpc-url $RPC # 3. A vault's real state: the pot, what claimFees would move, the pinned split, auto-pay, # the lock, the last activity and the current delivery. V= cast call $V "available()(uint256)" --rpc-url $RPC cast call $V "pendingFees()(uint256)" --rpc-url $RPC cast call $V "pinnedCreatorTaxBps()(uint16)" --rpc-url $RPC cast call $V "pinnedReflections()(bool)" --rpc-url $RPC cast call $V "launchLocked()(bool)" --rpc-url $RPC cast call $V "payoutAddress()(address)" --rpc-url $RPC cast call $V "pendingPayoutAddress()(address)" --rpc-url $RPC cast call $V "pendingPayoutActivatesAt()(uint64)" --rpc-url $RPC cast call $V "claimNonce()(uint256)" --rpc-url $RPC cast call $V "lastActivity()(uint64)" --rpc-url $RPC cast call $V "delivery()((uint256,address,uint64,uint16,address,bool))" --rpc-url $RPC cast call $V "coins(uint256)((address,address,address,address,uint16,bool,uint64,address))" 0 --rpc-url $RPC # 4. The coin's splitter holds the pinned split R/P/C, and the escrow balance it can pull. S= cast call $S "R()(uint16)" --rpc-url $RPC # T + 70 cast call $S "P()(uint16)" --rpc-url $RPC # 15 cast call $S "C()(uint16)" --rpc-url $RPC # reflections ? 50 + (T-100)/2 : 0 cast call $S "pending()(uint256)" --rpc-url $RPC PT=0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 cast call 0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9e "balanceOfToken(address,address)(uint256)" $S $PT --rpc-url $RPC # 5. Reflections are a real stream: reward rate, window end and total weight. ST= cast call $ST "rewardRate()(uint256)" --rpc-url $RPC cast call $ST "periodFinish()(uint256)" --rpc-url $RPC cast call $ST "totalWeight()(uint256)" --rpc-url $RPC cast call $ST "earned(address)(uint256)" --rpc-url $RPC # 6. The API mirrors the chain. /status reports lag between indexed and head block; totals are # base-unit strings; the event kinds are exactly the ones the indexer decodes. curl -s "$API/status" curl -s "$API/vaults?limit=5" curl -s "$API/activity?kind=claimed&limit=10" curl -s "$API/handles/xpaydaymoney" HANDLE=xpaydaymoney curl -s "$API/h/$HANDLE/summary" # 7. The contract tests (fork tests run against the real Pons contracts). cd contracts && forge test # 8. The local, end-to-end lifecycle on an anvil fork of chain 4663 (anvil + API with # DEV_FAKE_X=1; see ops/dev for the exact scripts). ops/dev/anvil.sh ops/dev/deploy-local.sh bun ops/dev/e2e.ts ``` If any live read disagrees with a number in this document, the live read is correct and this document is stale on that point.