PULLPULL
PULL · Finance documentation

Capital has inertia.

PULL pulls fragmented capital into Robinhood Chain. An asset is locked in a single-purpose vault on Ethereum, a message is delivered by a configured messenger, and a PULL representation (pToken) is minted 1:1 on Robinhood Chain. The reverse path burns the pToken and unlocks the collateral. Proof of Pull compares the two sides on-chain, continuously.

What the interface promises

  • Every number is read from a contract or an RPC. There is no seeded TVL, no sample transaction, no placeholder address.
  • A feature whose configuration is missing is disabled and labelled NOT CONFIGURED / NOT DEPLOYED.
  • A reserve that cannot be read is UNVERIFIED, never “100% backed”.
  • $PULL market data (price, liquidity, volume, holders) is read server-side from GeckoTerminal and Robinhood Explorer by /api/token and labelled by source; curve reserves are read on-chain.
  • Bridge stages derive from receipts, events and contract views — not timers.

Architecture

src/config        env · chains · assets · contracts · brand   (single source of configuration)
src/lib/chain     viem public clients (browser → /api/rpc/<chain> relay)
src/lib/reserves  Proof of Pull engine (pinned-block reads on both chains)
src/lib/bridge    BridgeAdapter interface + adapters/native · adapters/layerzero
src/lib/swap      SwapAdapter interface + adapters/uniswapV2
src/lib/liquidity factory/pair enumeration
src/lib/history   HistoryAdapter: rpc-logs (default) · indexer (when configured)
src/lib/router    Pull Router: bridge leg + swap leg composition
src/hooks         TanStack Query + wagmi hooks; no transaction logic in components
contracts/        Foundry workspace: PullVault · PullToken · PullBridge · IPullMessenger

Bridge

PullBridge is deployed once per chain. On the source chain each route points at a PullVault; on Robinhood Chain each route points at a PullToken. The bridge does not implement a messaging protocol: it calls IPullMessenger.send and accepts receiveMessage only from the messenger it was configured with. A LayerZero OApp, a native rollup messenger or a test double can sit behind that interface.

wrap    user → approve(vault) → bridge.bridgeOut{value: fee}(USDC, amount, recipient)
        vault.depositFrom(user, amount) → totalLocked += received
        messenger.send(dstEid, abi.encode(nonce, pUSDC, recipient, received))
        … remote messenger → bridge.receiveMessage(srcEid, payload)
        pUSDC.mintFromBridge(recipient, received)

unwrap  user → bridge.bridgeOut{value: fee}(pUSDC, amount, recipient)   // burns caller's pUSDC
        … → vault.withdrawTo(recipient, amount)   // subject to the rolling withdraw limit

Frontend adapters live in src/lib/bridge/adapters. The native adapter quotes the messaging fee from the bridge contract, prepares the write for the wallet (it never signs), and tracks completion via processed(nonce) and the BridgeCompleted event on the far chain. The LayerZero adapter reports NOT CONFIGURED: no LayerZero endpoint is deployed on Robinhood Chain (checked on-chain), so the live transport is PullRelayMessenger — an operator-signed relayer.

Proof of Pull

For each asset, at a pinned block per chain:

source       token.balanceOf(vault)   vault.totalLocked()   token.decimals()
destination  pToken.totalSupply()     pToken.decimals()

backingBps = balanceOf(vault) · 10_000 / totalSupply     (decimals aligned)
≥ 100.00%  FULLY BACKED · 99–100%  WATCH · < 99%  UNDERCOLLATERALIZED
unreadable → UNVERIFIED · unconfigured → AWAITING DEPLOYMENT

The inspector shows every raw read with its contract, method, value and block, with copy and explorer actions. The classification lives in src/lib/reserves/engine.ts and is never stored.

Swap & liquidity

Swaps run through a router exposing the Uniswap V2 interface on Robinhood Chain. Route discovery tries the direct pair, then a two-hop route through WETH. Quotes come from getAmountsOut; price impact is derived from pair reserves along the route. Quotes older than 30 seconds are re-fetched before signing.

Pull Router

The router composes the bridge leg and the swap leg into one plan with per-stage availability, fees and outputs. It is explicit that execution requires two signatures on two chains and is not atomic.

Configuration

NEXT_PUBLIC_APP_ENV selects the network pair: mainnet → Ethereum (1) → Robinhood Chain (56); anything else → Sepolia (11155111) → Robinhood Chain Testnet (97). All addresses come from .env; see .env.example.

Security

  • Roles: DEFAULT_ADMIN (routes, messenger, limits), PAUSER (pause/unpause), BRIDGE_ROLE (vault deposit/withdraw, token mint/burn) — granted only to the bridge contract.
  • No public mint; no tx.origin; SafeERC20; ReentrancyGuard on every fund-moving path; Pausable everywhere.
  • Vault accounting uses the amount actually received, so fee-on-transfer tokens cannot inflate supply.
  • Replay protection by nonce on the receiving bridge; wrong-source and unauthorized-messenger reverts.
  • Invariant tests: wrapped supply ≤ locked collateral; exact accounting including in-flight messages; vault holds what it reports.
  • Contracts are experimental until independently audited.