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.
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 receive | psIPHONE shares | AAPL, TSM, QCOM… in your wallet |
| Contract called | PhotoswapFactory.zapMintOrCreate | UniversalRouter.execute |
| Sized by | Live NAV: every leg fills the same share count | Basket weights |
| Slippage bound | minShares (100 bps) | minOut per leg (100 bps) |
| Leftovers | Refunded to you in the same transaction | None: exact-in swaps |
| When it is used | Whenever a factory address is configured | The 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.
| Fee | Tick spacing | Used by |
|---|---|---|
| 50000 (5%) | 1000 | Stock pools |
| 10000 (1%) | 200 | Probed, rarely funded |
| 3000 (0.3%) | 60 | Probed, rarely funded |
| 500 (0.05%) | 10 | ETH/USDG |
| 100 (0.01%) | 1 | Probed, 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.
| Action | Byte | Role in the basket |
|---|---|---|
| SWAP_EXACT_IN_SINGLE | 0x06 | One per hop. A via leg is two of these, the second with amountIn = OPEN_DELTA. |
| SETTLE_ALL | 0x0c | Once, for native ETH, max = totalWei. Paid from the router balance funded by execute{value}. |
| TAKE_ALL | 0x0f | Once per distinct stock, min = Σ minOut. Pays the router's msg.sender: you. |
| SETTLE / TAKE | 0x0b / 0x0e | Used 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.
| Leg | Weight | Route | Tier |
|---|---|---|---|
| AAPL | 40% | ETH → USDG → AAPL | 5% |
| TSM | 27% | ETH → TSM | 5% |
| SKHY | 18% | ETH → USDG → SKHY | 5% |
| AVGO | 15% | ETH → USDG → AVGO | 5% |
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 100Execute
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.
walletErrorMessagemaps the RPC's insufficient-funds error to one line. - Price moved. Any leg below its
minOutreverts the whole basket. Nothing is partially bought. - No liquidity. The leg is dropped at planning and named in the note under the plan.
Atomic
