Docs

How the ferry runs

Everything Ferry sends, to which contract, and why — and how each claim on this site is checked against both chains.

What Ferry is

Robinhood Chain is an Arbitrum Nitro rollup that settles on Ethereum. Its bridge — the Delayed Inbox, Bridge and Outbox contracts Robinhood publishes — can deliver a message from Ethereum along with ETH. Arbitrum calls such a message a retryable ticket: a call that Robinhood Chain executes on arrival, paid for with the ETH it carried.

Ferry is a page that writes that ticket for you. Its call is a Uniswap purchase on Robinhood Chain whose recipient is your own address. You sign one Ethereum transaction; about 10 minutes later you hold the stocks. Ferry deploys no contract, holds no key and takes no fee. The whole engine is one file, js/ferry.js, which the page, the tests and the browser run all import.

The crossing

On Ethereum: one transaction

Inbox(0x1A07…7a2D).createRetryableTicket{value: deposit}(
  to                     = SwapRouter02 on Robinhood Chain,
  l2CallValue            = the ETH to spend,
  maxSubmissionCost      = 4 × the bridge's fee at today's Ethereum base fee,
  excessFeeRefundAddress = you,
  callValueRefundAddress = you,
  gasLimit               = the estimate for THIS ticket + 30% + 60,000,
  maxFeePerGas           = 4 × Robinhood Chain's base fee + your gas money ÷ gasLimit,
  data                   = SwapRouter02.multicall(deadline, [exactInput(…) per stock]))
deposit = l2CallValue + maxSubmissionCost + gasLimit × maxFeePerGas   — exactly, never more

On Robinhood Chain: the ticket runs

ArbOS creates the ticket and immediately tries it: SwapRouter02.multicall with the ticket's ETH as msg.value. Each exactInput spends its share of that ETH as WETH through the stock's route — usually WETH → USDG in the 0.01% pool, then USDG → stock in the stock's deepest pool — with recipient set to your address and amountOutMinimum set to the live quote less your price limit. The shares of every leg add up to the ETH exactly, so nothing is left in the router.

Following it across

The ticket's id is Arbitrum's SubmitRetryable transaction hash: keccak256(0x69 ‖ rlp[chainId, messageNumber, aliased sender, Ethereum base fee, deposit, maxFeePerGas, gasLimit, to, l2CallValue, callValueRefund, maxSubmissionCost, feeRefund, data]). Ferry reads every field from your Ethereum receipt (the Inbox's InboxMessageDelivered and the Bridge's MessageDelivered), computes the id, and asks Robinhood Chain for that transaction. Its RedeemScheduled event names the purchase's own transaction; that receipt says what reached you.

Gas money

A new arrival on Robinhood Chain has no ETH for gas, and gas there is paid in ETH. Two facts about ArbOS (arbos/tx_processor.go in Offchain Labs' nitro) decide how Ferry delivers some:

  • Before the ticket's call runs, ArbOS refunds (maxFeePerGas − baseFee) × gasLimit to the ticket's excessFeeRefundAddress. Ferry sets the fee cap high on purpose, so that refund is your gas money — about 0.0003 ETH unless you choose more — and it arrives whether or not the purchase succeeds.
  • Any deposit above the ticket's cost stays at your aliased address (your address plus 0x1111…1111), which you cannot spend from. So Ferry's deposit is the exact sum, to the wei.

Refunds go to your address only if it has no code on Ethereum; the Inbox re-addresses refunds to contracts. Ferry therefore refuses to board from a smart-contract wallet or an EIP-7702 delegated account.

If the price moves

A crossing takes minutes, and prices move. Each purchase carries its own minimum; if any pool pays less, the whole multicall reverts and nothing is bought. The ticket then waits on Robinhood Chain for seven days with your ETH in it. Ferry's tracker shows it as Waiting and offers two things, both ordinary Robinhood Chain transactions paid from your gas money:

  • Try again — ArbRetryableTx.redeem(ticket) runs the same purchase with the same minimums. Anyone may call it.
  • Take the ETH — ArbRetryableTx.cancel(ticket) pays the ticket's ETH to you (the beneficiary). Then buy on Robinhood Chain at today's price from the Cross page, in its "ETH already on Robinhood Chain" mode.

A ticket nobody touches for seven days expires and pays its ETH to you as well. The purchase also refuses to run after a three-hour deadline, so a ticket delayed by a slow sequencer cannot buy at a stale price.

Where Robinhood has a price feed for the stock and it is fresh, Ferry also refuses to board a leg priced more than 3% worse than the feed.

The way home

  1. Sell to ETH, one Robinhood Chain transaction: SwapRouter02.multicall(deadline, [selfPermit(stock, …)…, exactInput(stock → … → WETH, recipient = router, minimum)…, unwrapWETH9(Σ minimums, you)]). Every Robinhood stock token and USDG accept EIP-2612 permits, so you sign a message per stock instead of sending approvals.
  2. Send it home: ArbSys(0x64).withdrawEth(you) with the ETH as value. Robinhood Chain records an L2ToL1Tx addressed to you.
  3. Claim on Ethereum once the rollup has confirmed a Robinhood Chain block past your withdrawal. Ferry reads the latest confirmed assertion from the rollup (latestConfirmed, getAssertion, its AssertionCreated event), checks that the block it names carries the same send root, asks Robinhood Chain's NodeInterface for the proof (constructOutboxProof), and your wallet sends Outbox.executeTransaction.

The confirmation period is set in the rollup: 45,818 Ethereum blocks, about 6.4 days.

What it costs

Ethereum gas for the boardingmeasured per ticket; the page shows it before you sign
The bridge's submission fee(1,400 + 6 × calldata bytes) × Ethereum base fee
Robinhood Chain gas for the purchase≈ 500,000–750,000 gas at its base fee
Uniswap pool fees0.01% for ETH → USDG, then the stock pool's own tier
Ferrynothing

Addresses

On Ethereum — Robinhood Chain's bridge (from docs.robinhood.com)

On Robinhood Chain

Uniswap QuoterV20x33e885eD0Ec9bF04EcfB19341582aADCb4c8A9E7
WETH (the bridge's; SwapRouter02.WETH9)0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73
USDG0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
ArbSys · ArbRetryableTx · NodeInterface0x…64 · 0x…6E · 0x…C8

How it is tested

The last run: 11/11 properties and 6,206 checks passed against live state on both chains (30 Sep 2026).

Then a sabotage sweep plants 33 bugs, one at a time, in a copy of js/ferry.js and requires the property named for each one to fail: 33/33 were caught.

The browser run: 7/7 journeys (30 checks) clicked through the real pages in Chrome with a test wallet, against private copies of both chains (30 Sep 2026).

PropertyWhat it provedChecks
split400 random splits of 1–4 legs added up to the wei, and every minimum was floor(quote × (1 − limit)) exactly; 4/4 invalid splits refused3,401
deposit300 random tickets: deposit = call value + submission cost + gas limit × fee cap to the wei, gas money ≥ what was asked, refunds to the owner, calldata equal to the ticket2,700
boardthe real Inbox accepted a 3-leg boarding (118,904 gas); the receipt read back to every field of the ticket, sender aliased, refunds to the owner; one wei short → InsufficientValue, fee − 1 → InsufficientSubmissionCost, a wallet with code → refunds re-addressed to its alias10
arrivefrom the aliased sender with the ticket's ETH, SwapRouter02 bought NVDA (via USDG), META (direct WETH pool) and USDG for the owner, each ≥ its minimum and within 1% of its quote, in 461,534 of the ticket's 772,448 gas; nothing to the alias, nothing left in the router; a minimum above the pool → "Too little received"; past the deadline → "Transaction too old"20
gasNodeInterface estimates 548,046 gas for this ticket; it carries 772,448. Fee cap 0.478 gwei over a 0.0227 gwei base fee delivers 0.000351763285646784 ETH of gas money — about 51 300k-gas transactions3
Show the other 6
ids8 real tickets from the last scan: parsed from their Ethereum receipts, the computed id is the ticket Robinhood Chain created, with the same message number, deposit and aliased sender; a deposit one wei off gives an id nobody created26
status6 real tickets whose purchase ran read as arrived, naming the purchase transaction and every transfer to the owner; a ticket that does not exist reads as still crossing14
sell3 legs (NVDA via USDG, META via its WETH pool, USDG) sold in one transaction with 3 real permit signatures: each balance fell by exactly its amount, the owner gained 0.109424566486491103 ETH (≥ Σ minimums, within 1% of the quotes), the router kept nothing; a permit signed by another key → refused12
sendArbSys.withdrawEth took exactly 0.0123456789 ETH and recorded an L2ToL1Tx to the owner, which the page reads back field for field3
claimlatest confirmed Robinhood Chain block 71,250,042 (send count 2701) matches the Outbox's own root; for 3 real withdrawals the page's proof hashes to that root and Outbox.executeTransaction answers as it must (AlreadySpent for claimed ones, success for unclaimed); one flipped proof bit → UnknownRoot12
guardNVDA's pool 11% above a fresh Robinhood feed → refused; at the feed's own price → boarded; ETH mixed with stocks, shares that add to 90%, and an unknown stock → all refused5

Limits and risks

  • Nothing here is audited as a whole. The contracts Ferry calls are Robinhood's bridge and Uniswap's router, each audited by their authors; the page that writes the bytes is Ferry's own and is tested, not audited.
  • The price can move. Then nothing is bought and your ETH waits on the ticket (see above). A limit that is too tight means more waiting tickets; one that is too loose means a worse price.
  • The sequencer decides when tickets arrive. Measured at 10 minutes at the median; if Robinhood's sequencer stopped, Arbitrum's force-inclusion lets messages through after a day.
  • Coming home takes 6.4 days, and the claim costs Ethereum gas.
  • Tokenized stocks are Robinhood's. Robinhood can pause a stock, block an address or burn tokens; they are not shares held in your name at a broker.