HoodV4SwapAdapter
contracts/src/periphery/HoodV4SwapAdapter.sol · Solidity 0.8.26
A single-purpose exact-input swap venue for the HoodZapRouter, swapping directly against the Uniswap v4 PoolManager. It exists because on Robinhood Chain v4 is the only real liquidity venue, and v4's UniversalRouter pulls input via Permit2 — incompatible with the router's plain-ERC20-allowance model. This adapter uses a normal transferFrom instead (satisfied by the router's forceApprove), so the existing router needs no changes. It holds nothing between calls.
- Inherits:
ReentrancyGuard.
Routes
- Single-hop — swap directly in one
USDG/<token>v4 pool. - Multi-hop (USDG → ETH → token) — on Robinhood Chain the deepest pools for several stocks pair against native ETH, not USDG, so a 2-hop route often beats the shallow direct pool. In a single v4 unlock the intermediate ETH nets to a zero delta between hops, so it is never transferred — only the first input (USDG, an ERC-20) is settled and only the final output (the stock) is taken. The adapter therefore never moves native ETH and stays a pure-ERC20 venue.
Constructor & state
constructor(address poolManager) // sets pm
IV4PoolManager public immutable pm; // the Uniswap v4 PoolManager
Functions
function swapExactIn(
V4PoolKey calldata key, bool zeroForOne,
uint256 amountIn, uint256 minAmountOut, address recipient
) external nonReentrant returns (uint256 amountOut)
function swapExactInMultiHop(
V4PoolKey[] calldata keys, bool[] calldata zeroForOne,
uint256 amountIn, uint256 minAmountOut, address recipient
) external nonReentrant returns (uint256 amountOut)
function unlockCallback(bytes calldata raw) external returns (bytes memory) // only pm may call
swapExactIn— single-hop exact-input swap ofamountIninto the pool's other side, delivering output torecipient. The caller must have approved the adapter foramountInof the input token.swapExactInMultiHop— chained exact-input swap alongkeys[i]/zeroForOne[i](e.g. USDG → ETH → stock). Arrays must be equal length and non-empty. Only the first hop's input is pulled/settled and only the last hop's output delivered.unlockCallback— the v4 unlock callback; revertsOnlyPoolManager()for any other caller. Runs each hop throughpm.swapwith a negativeamountSpecified(exact input), chaining each hop's output currency into the next.
A DustLeft() guard fails the swap if any input remains on the adapter afterward, so an exact-in swap must consume the whole input or revert loudly.
Errors & events
event Swapped(address indexed caller, address indexed tokenIn, address indexed tokenOut,
uint256 amountIn, uint256 amountOut);
error OnlyPoolManager(); // unlockCallback caller != pm
error ZeroAmount(); // amountIn == 0
error EmptyPath(); // multi-hop arrays empty or mismatched
error BrokenPath(); // a hop's input != the previous hop's output currency
error MinOut(); // final amountOut < minAmountOut
error DustLeft(); // input-token remained after the swap
error NoOutput(); // a hop produced no output
Zap flow end-to-end
How a one-click buy becomes shares, from the API to the chain.
1. Allocation (backend)
The backend splits the input usdgIn across constituents by current on-chain value weight, not stored target weights. For each token it reads trackedBalance and the Chainlink price, computes legValue_i = trackedBalance_i × price_i (decimal-normalized), and allocates usdgLeg_i = usdgIn × legValue_i / totalValue (the last leg absorbs rounding dust so the legs sum exactly to usdgIn). Spending USDG in proportion to trackedBalance_i × price_i makes the delivered tokens proportional to trackedBalance_i, so the basket's worst-leg pro-rata credit comes out equal across legs. Any stale/missing/zero price aborts the quote (never prices off a bad feed).
2. Best route per leg
Each enabled venue adapter is queried in parallel. For each candidate, impactBps is measured against the Chainlink-implied output; candidates within MAX_IMPACT_BPS (1000 = 10%) survive, and the one with the largest output wins. If no venue survives a leg, the whole quote fails with 409 quote unavailable. The HoodV4 adapter itself picks between the direct USDG pool and the USDG→ETH→token route, discovering the deepest pool across fee tiers via v4's StateView and pricing with the v4 Quoter (no fee tier is hardcoded).
3. Expected shares & floor
Expected shares mirror Basket.mintFromDeposit exactly: expectedGross = min_i (amountOut_i × totalSupply / trackedBalance_i), minus the entry fee, gives expectedNet. minShares = expectedNet × (10000 - slippageBps) / 10000.
4. The transaction
The /v1/zap/quote response contains a ready-to-submit tx:
tx.to= the deployed HoodZapRouter address.tx.data= ABI-encodedzapIn(basket, usdgIn, legs, minSharesOut)(orzapOut(...)), where eachlegs[i]=SwapLeg { venue, data }targets an allowlisted venue (the HoodV4SwapAdapter) whose swaprecipientis the router.tx.value="0"— all inputs are ERC-20; even the via-ETH route nets ETH to zero inside the PoolManager.
5. Client submission
The on-chain floor is the real protection, not the backend's coarse impact filter. Before submitting, the client can independently recompute minSharesOut/minUsdgOut from the returned per-leg amountOuts and re-decode tx.data to substitute its own floor — enforced on-chain by Basket.mintFromDeposit's minSharesOut (zap-in) and zapOut's usdgOut >= minUsdgOut check (zap-out). Quotes carry a quoteExpiresAt ~5 seconds out.