Docs Buying a basket

Buying a basket

By default a buy mints shares of the object's ETF through the factory. The direct swap, one UniversalRouter execute with native ETH, is the alternative and the fallback.

ETHAppleAAPL40%SK HynixSKHY18%BroadcomAVGO15%TSMCTSM27%
The simulated $100 iPhone basket. One execute, one V4_SWAP command. AAPL, SKHY and AVGO route through USDG; TSM goes direct on the 5% tier.

Two ways to buy

The buy panel has two modes. The default, once the factory is deployed, is the object ETF: your ETH goes to the factory, which runs the swaps below and mints you shares of the object's own ERC-20. The alternative, behind Buy the stocks directly instead, is the original direct swap, which lands every stock in your wallet as separate positions. Both are one transaction, and both use exactly the same router calldata; the only difference is who receives the stocks.

Object ETF (default)Direct swap
You receivepsIPHONE sharesAAPL, TSM, QCOM… in your wallet
Contract calledPhotoswapFactory.zapMintOrCreateUniversalRouter.execute
Sized byLive NAV: every leg fills the same share countBasket weights
Slippage boundminShares (100 bps)minOut per leg (100 bps)
LeftoversRefunded to you in the same transactionNone: exact-in swaps
When it is usedWhenever a factory address is configuredThe toggle, or when no factory is deployed

Until NEXT_PUBLIC_PHOTOSWAP_FACTORY is set the panel shows a one-line note, ETF minting goes live with the factory deployment, and behaves exactly as described below.

Plan

planBasketBuy in lib/basket.ts takes a basket, a dollar amount, the ETH price and a slippage bound, and returns a BasketPlan. It converts dollars to wei, splits the wei by weight so the parts sum exactly to the total, quotes every leg in parallel, and drops what cannot be routed.

const plan = await planBasketBuy({ basket, totalUsd: 100, ethUsd, slippageBps: 100 });
// plan.legs        PlannedLeg[]  amountInWei, amountOut, minOut, fee, tickSpacing, route
// plan.totalWei    Σ amountInWei, the value of the execute tx
// plan.unroutable  companies with a contract but no v4 liquidity for this size
// plan.deadline    unix seconds, now + 1800
// plan.encoding    "settle_all_take_all"

Quoting runs at most twice. If the first round drops a leg, the weights are renormalised over the survivors and everything is re-quoted at the new sizes. The buy screen shows describePlan, which converts each leg's raw output to shares with the multiplier and values it at Robinhood mid.

Routes

quoteBestRoute tries two paths per leg and keeps the one with more stock out. Direct is ETH → stock in one pool. Via is ETH → USDG, then USDG → stock. Many stock pools are only funded against USDG, and a few ETH pools hold dust, so both are always tried.

Each single hop is probed across every tier in V4_TIERS with simulateContract on the V4Quoter, because quoteExactInputSingle is nonpayable and cannot be called as a plain read. The best tier wins.

FeeTick spacingUsed by
50000 (5%)1000Stock pools
10000 (1%)200Probed, rarely funded
3000 (0.3%)60Probed, rarely funded
500 (0.05%)10ETH/USDG
100 (0.01%)1Probed, rarely funded

Encoding

The whole basket is one call to UniversalRouter.execute(commands, inputs, deadline) at 0x8876789976decbfcbbbe364623c63652db8c0904. There is a single command byte, V4_SWAP (0x10), whose input is a packed list of v4 actions and their parameters.

ActionByteRole in the basket
SWAP_EXACT_IN_SINGLE0x06One per hop. A via leg is two of these, the second with amountIn = OPEN_DELTA.
SETTLE_ALL0x0cOnce, for native ETH, max = totalWei. Paid from the router balance funded by execute{value}.
TAKE_ALL0x0fOnce per distinct stock, min = Σ minOut. Pays the router's msg.sender: you.
SETTLE / TAKE0x0b / 0x0eUsed by the alternative settle_take_open_delta encoding.
[ SWAP_EXACT_IN_SINGLE × (hops) …, SETTLE_ALL(ETH, totalWei), TAKE_ALL(token_i, minOut_i) × unique tokens ]

Native ETH means no approval, no Permit2 and no WETH wrap. The user signs exactly one transaction with value = totalWei. Three encodings exist in encodeV4BasketSwap; settle_all_take_all is the default because it is the one proven live.

The custom periphery

Robinhood Chain runs a modified v4-periphery. Its ExactInputSingleParams tuple has an extra field, minHopPriceX36 (uint256), between amountOutMinimum and hookData. Encoding against the stock Uniswap ABI reverts. Photoswap sets it to 0 and enforces slippage with amountOutMinimum.

export const EXACT_IN_SINGLE_PARAMS = [{
  type: "tuple",
  components: [
    { name: "poolKey", type: "tuple", components: POOL_KEY_COMPONENTS },
    { name: "zeroForOne", type: "bool" },
    { name: "amountIn", type: "uint128" },
    { name: "amountOutMinimum", type: "uint128" },
    { name: "minHopPriceX36", type: "uint256" },   // custom on chain 4663
    { name: "hookData", type: "bytes" },
  ],
}] as const;

Slippage and deadline

Default slippage is 100 bps. Each leg's minOut = amountOut × (10000 − 100) / 10000. The deadline is 30 minutes from planning (swapDeadline(1800)); executeBasketBuy refuses an expired plan and asks for a re-plan rather than sending something the router will reject.

The simulated iPhone basket

scripts/simulate-basket.mts proves the encoding against mainnet without spending anything. It plans a $100 buy of AAPL 40 / TSM 27 / SKHY 18 / AVGO 15, then eth_calls the router from a fake sender whose balance is set to 10 ETH with a state override. All three encodings are tried and reported.

LegWeightRouteTier
AAPL40%ETH → USDG → AAPL5%
TSM27%ETH → TSM5%
SKHY18%ETH → USDG → SKHY5%
AVGO15%ETH → USDG → AVGO5%

The result: settle_all_take_all does not revert and estimates at about 511k gas for four legs, six hops. Run it yourself:

export PATH=/opt/homebrew/bin:$PATH && npx tsx scripts/simulate-basket.mts 100

Execute

executeBasketBuy(walletClient, plan) builds the transaction with buildBasketTx, runs estimateGas first so a revert surfaces before the wallet prompt, adds 25% headroom, and sends. It returns the hash; waitForBasket waits for one confirmation.

What can go wrong

  • Wrong chain. The Buy pill shows Switch to Robinhood Chain until the wallet is on 4663. See Wallets.
  • Not enough ETH. walletErrorMessage maps the RPC's insufficient-funds error to one line.
  • Price moved. Any leg below its minOut reverts the whole basket. Nothing is partially bought.
  • No liquidity. The leg is dropped at planning and named in the note under the plan.

Atomic

One transaction, all legs or none. If the router reverts on any hop, you keep your ETH minus gas.