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 exitredeemInKind is permissionless and can never be paused.

2. Architecture

Three layers:

  1. Smart contracts (this repo, contracts/) — Whitelist, OracleLib, Basket, BasketFactory, PonsZapRouter, PonsV4SwapAdapter, plus PonsV4PriceFeed / PonsFeedFactory.
  2. Backend — Node/Hono/Postgres indexer + REST API: event indexing, NAV computation, leaderboards, zap quotes. Holds no funds. (Roadmap.)
  3. 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…dEaD at 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

  1. Pick 2–20 whitelisted Pons coins, assign weights.
  2. Set entry (≤300 bps), exit (≤100 bps), management (≤300 bps/yr).
  3. Seed: the wizard converts weights + a target price-per-share into seedUnits[i] (token i per 1e18 shares) and seedShares; the factory pulls ceil(seedUnits[i] × (seedShares + DEAD_SHARES) / 1e18) of each token.
  4. BasketFactory.createBasket clones → transfers seed → initialize, atomically.
  5. Creator receives seedShares; DEAD_SHARES locked.
  6. 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 allowlisted PonsV4SwapAdapter; the router forwards everything it received to the vault and calls mintFromDeposit. 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

FeeCapWhenMechanism
Entry300 bpsevery mint (zap or in-kind)deducted from gross shares; fee shares minted 90/10
Exit100 bpsevery redeemfee shares transferred from redeemer 90/10; the rest burned
Management300 bps/yrcontinuouslytotalSupply × 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); Δ permissionless registerPonsToken(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) → basket
  • setCurator, setOpenCreation, setMintPaused(basket, p); views allBaskets, 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; errors VenueNotAllowed, BadVenue, NotABasket, SwapFailed, Slippage

PonsV4SwapAdapter

  • swapExactIn(PoolKey, zeroForOne, amountIn, minAmountOut, recipient) payable → amountOut
  • unlockCallback — swap, settle input (native via settle{value}, ERC-20 via sync/transferFrom/settle), take output to recipient. Reverts DustLeft if the exact input was not fully consumed.
  • Δ single-hop only (all Pons pools are token/ETH). The Pons hook's afterSwapReturnDelta fee is reflected in the delta we read.

PonsCurveSwapAdapter (Δ new)

  • buyExactIn(token, ethIn, minTokensOut, recipient) payable → tokensOutcurve.buy, refund of a clamped fill forwarded to the caller
  • sellExactIn(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 reverts NotPonsCurve

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

  1. ✅ Contracts + unit tests + mainnet-fork test.
  2. Deploy to Robinhood mainnet, verify, register first coins, seed 3–5 curated baskets.
  3. Backend indexer + API (/v1/baskets, /v1/baskets/:addr, /v1/zap/quote, /v1/leaderboard, /v1/portfolio/:wallet).
  4. Web app.
  5. Docs site mirroring this spec.