PTF (Pons Trading Fund) protocol spec
Mirrors docs.hoodetf.org section by section. Where PTF differs, the difference is called out in a Δ block.
1. Overview
PTF (Pons Trading Fund) is an on-chain protocol for creating and trading baskets of Pons-launched memecoins on Robinhood Chain (chain id 4663). Each basket is a smart-contract vault that custodies the underlying tokens and issues ERC-20 shares. Buy with ETH in one click (zap) or deposit constituents in-kind; redeem in-kind at any time. Creators earn 90% of the fees their basket charges.
Design properties (unchanged from HoodETF):
- Real backing — the vault holds the actual tokens; shares are a pro-rata claim.
- Oracle-free core — mint and redeem are pro-rata on
trackedBalance; prices are for display, zap quoting and fee accounting only. - Non-custodial creation — baskets are deployed from the creator's wallet; the factory never holds funds.
- Immutable baskets — constituents and fees fixed at deploy; no admin key can seize or modify assets.
- Always-open exit —
redeemInKindis permissionless and can never be paused.
2. Architecture
Three layers:
- Smart contracts (this repo,
contracts/) — Whitelist, OracleLib, Basket, BasketFactory, PonsZapRouter, PonsV4SwapAdapter, plus PonsV4PriceFeed / PonsFeedFactory. - Backend — Node/Hono/Postgres indexer + REST API: event indexing, NAV computation, leaderboards, zap quotes. Holds no funds. (Roadmap.)
- Web app — Next.js, RainbowKit, SIWE. Discover / trade / portfolio / create. (Roadmap.)
Dependencies: Robinhood Chain (Arbitrum Orbit), Uniswap v4 (PoolManager, StateView, Quoter), Chainlink ETH/USD, Pons V2 launch factory + MemeHook.
3. Core concepts
- Basket — vault with 2–20 constituents issuing 18-decimal ERC-20 shares.
- DEAD_SHARES — 1e15 shares minted to
0x…dEaDat creation and locked forever (first-depositor guard). - Constituent / weight — weights (bps summing to 10 000) are a UI concept; on-chain the ratio is implied by
trackedBalance. - trackedBalance — internal accounting per constituent; stray transfers do not distort share math until a zap-mint syncs them.
- NAV —
Σ trackedBalance_i × price_i / 10^decimals_i, 1e18-scaled USD. Price-per-share = NAV / totalSupply. - Stale — set when a feed is too old / broken / sequencer recently down; zaps are disabled in the app, in-kind paths keep working.
- Wire format — every on-chain amount is a base-10 integer string; shares/NAV 18 dec, ETH 18 dec.
Δ Zap asset is native ETH, not USDG. Pons pools quote in ETH.
4. How it works
Create
- Pick 2–20 whitelisted Pons coins, assign weights.
- Set entry (≤300 bps), exit (≤100 bps), management (≤300 bps/yr).
- Seed: the wizard converts weights + a target price-per-share into
seedUnits[i](token i per 1e18 shares) andseedShares; the factory pullsceil(seedUnits[i] × (seedShares + DEAD_SHARES) / 1e18)of each token. BasketFactory.createBasketclones → transfers seed →initialize, atomically.- Creator receives
seedShares; DEAD_SHARES locked. - Immutable from here.
Buy
- Zap (one click):
PonsZapRouter.zapIn{value: eth}(basket, legs, minSharesOut). Legs (built by the quote API) swap ETH → each constituent through the allowlistedPonsV4SwapAdapter; the router forwards everything it received to the vault and callsmintFromDeposit. Unspent ETH is refunded. - In-kind:
previewMintInKind(shares)→ approve each token →mintInKind(shares, to).
Valuation
Display / zap / fee accounting only. See §5.
Sell / redeem
- In-kind:
redeemInKind(sharesIn, to)— burns net shares, sends the pro-rata slice of every constituent. - Zap out: approve router for shares →
zapOut(basket, sharesIn, legs, minEthOut)— redeem in-kind to the router, swap each constituent to ETH, pay out, sweep dust back.
Built-in arbitrage: if shares trade below NAV anyone can buy and redeem in-kind.
5. NAV & price-per-share
NAV = Σ_i trackedBalance_i × price_i / 10^decimals_i with price_i from OracleLib.readPrice(feed_i, …) normalised
to 1e18. A stale constituent contributes 0 and sets stale.
Δ Price feed for Pons coins: PonsPriceFeed implements AggregatorV3Interface (18 decimals) and reads the
launch phase from the Pons factory on every call:
phase 0 (bonding curve): ethPerToken = quoteReserve × 1e18 / tokenReserve (curve.getReserves())
phase 2 (v4 pool): ratioX128 = sqrtPriceX96² / 2^64 (token1 per token0, Q128)
ethPerToken = 2^128 × 1e18 / ratioX128 (ETH is currency0)
phase 1 / 3: no venue → answer 0 → stale
answer = ethPerToken × ETHUSD(1e18) / 1e18
updatedAt = ETH/USD feed's updatedAt
Pool key reconstruction (identical to PonsV2LaunchFactory._poolIdFor): currency0 = address(0), currency1 = token, fee = launch.poolFee, tickSpacing = launch.tickSpacing, hooks = memeHook. Only native-ETH-quote
launches are accepted. A coin that graduates while inside a basket keeps pricing with no admin action.
6. Fees
| Fee | Cap | When | Mechanism |
|---|---|---|---|
| Entry | 300 bps | every mint (zap or in-kind) | deducted from gross shares; fee shares minted 90/10 |
| Exit | 100 bps | every redeem | fee shares transferred from redeemer 90/10; the rest burned |
| Management | 300 bps/yr | continuously | totalSupply × bps × elapsed / (10 000 × 365d), floor, minted 90/10 |
protocolShares = fee × 1000 / 10 000, creatorShares = fee − protocolShares.
Worked example (unchanged): buying 100 gross shares at 2% entry → 98 to buyer, 1.8 creator, 0.2 treasury. Redeeming 100 shares at 1% exit → 99 shares' worth of tokens; 0.9 shares to creator, 0.1 to treasury.
7. Creator earnings
Creators are paid in shares of their own basket. Entry and management fees mint new shares to them; exit fees
transfer redeemer shares to them. A "claimable fees" action = accrueMgmtFee() then zapOut only the fee shares.
8. Contracts
Whitelist (Ownable2Step)
addToken(token, feed, maxStaleness, decimals)/removeToken(token)/setSequencerFeed(feed, grace)- Δ
setPonsFeedFactory(factory, maxStaleness); Δ permissionlessregisterPonsToken(token) → feed - views
isAllowed,feedOf,maxStaleness,tokenDecimals,entryOf - events
TokenAdded,TokenRemoved,SequencerFeedSet,PonsFeedFactorySet
OracleLib
readPrice(feed, maxStaleness, seqFeed, grace) → (price1e18, stale). Sequencer answer ≠ 0, future timestamps,
age > maxStaleness, answer ≤ 0, decimals > 18, or a reverting feed all yield (0, true).
Basket (Initializable, ReentrancyGuard, ERC-20)
initialize(InitParams)— validates count / fees / whitelist / duplicates;trackedBalance = live balance; mints DEAD_SHARES + seedShares.mintInKind(sharesOut, to) → unitsIn[],mintFromDeposit(to, minSharesOut) → netShares,redeemInKind(sharesIn, to) → unitsOut[],accrueMgmtFee(),setMintPaused(bool)(factory only)- views
allTokens,previewMintInKind,previewRedeemInKind,nav,pricePerShare,pendingMgmtFeeShares - events
MintInKind,RedeemInKind,ZapMint,MgmtFeeAccrued,MintPauseSet - invariants: redeem never pausable; mgmt fee floor-rounded; mint/redeem never read a feed; zap-mint is not escrow.
BasketFactory (Ownable2Step)
createBasket(tokens, seedUnits, seedShares, entryBps, exitBps, mgmtBps, name, symbol) → basketsetCurator,setOpenCreation,setMintPaused(basket, p); viewsallBaskets,isBasket,basketCount- errors
NotCurator,DuplicateToken,LengthMismatch,NotABasket
PonsZapRouter (Ownable2Step)
SwapLeg { venue, tokenIn (0 = ETH), amountIn, data }zapIn(basket, legs, minSharesOut) payable → netShares,zapOut(basket, sharesIn, legs, minEthOut) → ethOut,setVenue(venue, ok)- events
ZappedIn,ZappedOut,VenueSet; errorsVenueNotAllowed,BadVenue,NotABasket,SwapFailed,Slippage
PonsV4SwapAdapter
swapExactIn(PoolKey, zeroForOne, amountIn, minAmountOut, recipient) payable → amountOutunlockCallback— swap, settle input (native viasettle{value}, ERC-20 viasync/transferFrom/settle),takeoutput to recipient. RevertsDustLeftif the exact input was not fully consumed.- Δ single-hop only (all Pons pools are token/ETH). The Pons hook's
afterSwapReturnDeltafee is reflected in the delta we read.
PonsCurveSwapAdapter (Δ new)
buyExactIn(token, ethIn, minTokensOut, recipient) payable → tokensOut—curve.buy, refund of a clamped fill forwarded to the callersellExactIn(token, tokensIn, minEthOut, recipient) → ethOut— pulls tokens from the caller,curve.sell- the curve is resolved from the Pons factory (
getLaunchedToken(token).curve); anything else revertsNotPonsCurve
PonsPriceFeed / PonsFeedFactory (Δ new)
See §5. PonsFeedFactory.getOrCreateFeed(token) is CREATE2-deterministic (salt = token) and accepts launches in
phase 0 (curve) or 2 (pool). venueOf(token) tells integrators which adapter to route through.
9. Guides
Create a basket (app flow, to build)
Select constituents → weights → fees → name/symbol → target price-per-share → wizard computes seed amounts and
checks balances → approve factory → createBasket. In-kind funding always works; ETH-funded seeding uses the zap
adapter first.
Buy / sell / redeem (app flow, to build)
Zap: POST /v1/zap/quote {basket, ethIn, slippageBps} → {legs[], minSharesOut, expiresAt} → zapIn. In-kind:
preview → approvals → mintInKind. Redeem: redeemInKind. Zap out: quote → approve router → zapOut.
10. Security & risk
Guarantees: open unpausable redemption; oracle independence of mint/redeem; immutability; atomic creation; reentrancy guards; checks-effects-interactions in redeem; venue allowlist; on-chain fee caps.
Risks: memecoin volatility and rug potential of individual constituents (mitigated only by diversification); thin v4 liquidity → zap price impact; spot-price manipulation affects display/quotes only; smart-contract risk (unaudited); Chainlink ETH/USD staleness (24h heartbeat) flags NAV stale but never blocks redemption.
11. Build order
- ✅ Contracts + unit tests + mainnet-fork test.
- Deploy to Robinhood mainnet, verify, register first coins, seed 3–5 curated baskets.
- Backend indexer + API (
/v1/baskets,/v1/baskets/:addr,/v1/zap/quote,/v1/leaderboard,/v1/portfolio/:wallet). - Web app.
- Docs site mirroring this spec.