Contract
Two contracts and one adapter, all in contracts/src, Solidity 0.8.26, no OpenZeppelin (Robinhood Chain deployments have to stay small). Every function is listed here with what it requires and what it emits.
Zecpass (the hub)
Storage
| Field | Type | Meaning |
|---|---|---|
owner |
address | The treasury. Can change every parameter, withdraw vault ETH, pause. |
relayer |
address | The only caller of deliver. |
treasury |
address | Receives the fee on every pass. |
token |
address | $ZECPASS. Zero until the launch, then set by the owner or the relayer and locked with lockToken(). |
market |
IMarket | The PonsMarket adapter. |
feeBps |
uint16 | Fee on the ETH of every pass, at most 200 (2 percent). Default 50. |
maxPerOrder |
uint256 | Cap on one deliver, in wei. Default 0.5 ETH. |
dailyCap |
uint256 | Cap on the sum of deliver per UTC day. Default 5 ETH. |
paused |
bool | Stops deliver and sweep. Never stops withdraw. |
delivered[orderId] |
bool | Each order id delivers once. |
spentOnDay[day] |
uint256 | Wei delivered on block.timestamp / 1 days. |
totalOrders, totalEthIn, totalFees, totalTokensOut |
uint256 | Running totals, shown on the stats page. |
Functions
receive() — accepts ETH into the vault from anyone, emits Funded(from, amount). The market adapter also refunds through it during a curve buy; those refunds stay in the vault.
passOf(address beneficiary) → address — the CREATE2 address of the beneficiary's pass: keccak256(0xff, hub, bytes32(uint160(beneficiary)), keccak256(type(Pass).creationCode)). Pure derivation, no state, valid before deployment.
deliver(bytes32 orderId, address beneficiary, uint256 amountWei, uint256 minTokensOut) → tokensOut — rail one.
Requires: caller is relayer; not paused; delivered[orderId] false; amountWei <= maxPerOrder; spentOnDay[today] + amountWei <= dailyCap; vault balance at least amountWei; token set.
Effects: marks the order delivered, adds to the day's spend, runs the buy, emits Delivered(orderId, beneficiary, 1, ethIn, fee, tokensOut).
sweep(address beneficiary, uint256 minTokensOut) → tokensOut — rail two, permissionless.
Requires: not paused; token set; the pass holds ETH.
Effects: deploys the Pass at passOf(beneficiary) if it has no code yet, calls drain() on it to pull the ETH into the hub, runs the buy with exactly that amount, emits Swept(pass, beneficiary, ethIn, fee, tokensOut).
withdraw(address beneficiary) — the escape hatch.
Requires: msg.sender == beneficiary. Works while paused and while the token is unset.
Effects: deploys the pass if needed, moves its whole ETH balance to the beneficiary, emits Withdrawn(pass, beneficiary, amount).
Owner: setToken(address) (owner or relayer, until lockToken(); the relayer is allowed so that launch day needs no treasury signature), lockToken() (owner only, needs a token set), setOwner, setRelayer, setTreasury, setFee(uint16 ≤ 200), setCaps(maxPerOrder, dailyCap), setMarket, pause(bool), withdrawVault(amount, to), rescueToken(erc20, to).
The buy, internally
fee = amountWei * feeBps / 10_000 → treasury, by call, revert if it fails
tokensOut = market.buy{value: amountWei - fee}(token, minTokensOut) → tokens arrive at the hub
token.safeTransfer(beneficiary, tokensOut)
totals += …
The tokens forwarded are the hub's balance delta, not the market's return value, so a token with a transfer tax could never strand anything in the hub. receive() uses a transient flag so that ETH pulled from a pass, or refunded by the curve mid-buy, is not announced as a Funded deposit.
One Pons detail the relayer respects: buys in the same block as the launch are taxed 99 percent by the curve's anti-snipe rule, and the rate falls to the normal level a few seconds later. A sweep is never that fast in practice, but a manual sweep in the launch block would be.
minTokensOut is a slippage floor. The relayer passes 0: on a bonding curve the price at the moment of the sweep is the price, and a failed sweep would leave ETH sitting in a pass for nothing. A user calling sweep themselves can set it.
Invariants
- ETH in a pass can only go two places: the hub during a
sweep(and then to the treasury and the market in the same transaction), or the beneficiary duringwithdraw. ThePasscontract has no other function. - The hub never holds user tokens across transactions:
_buytransfers everything the market returned. delivercan never spend more thandailyCapper UTC day, whatever the relayer key does.withdrawcannot be blocked by the owner. There is no function that pauses it, andsetMarketdoes not affect it.- Fee is bounded by 2 percent at the ABI level.
Events
Funded(address indexed from, uint256 amount)
Delivered(bytes32 indexed orderId, address indexed beneficiary, uint8 rail, uint256 ethIn, uint256 fee, uint256 tokensOut)
Swept(address indexed pass, address indexed beneficiary, uint256 ethIn, uint256 fee, uint256 tokensOut)
Withdrawn(address indexed pass, address indexed beneficiary, uint256 amount)
TokenSet(address) RelayerSet(address) FeeSet(uint16) CapsSet(uint256, uint256) Paused(bool)
Pass
Twenty lines. hub is set to the deployer in the constructor, receive() accepts ETH, drain() and drainTo(address) move the balance and can only be called by the hub. It has no owner, no upgrade path and no other entry point. Because the address is derived from the beneficiary and the hub only, a pass can be paid before it is deployed and by anyone: Relay, a NEAR Intents solver, or a friend.
PonsMarket
Copied from an earlier Robinhood Chain project where it was tested against the live Pons V2 on a fork. It knows the phases of a Pons token:
| Phase | Where the buy goes |
|---|---|
| 0, curve | curve.buy{value}(quoteIn, minTokensOut, recipient); the curve refunds any unfillable remainder, the adapter flushes it back to the caller |
| 1, swept | the curve is full but the pool does not exist yet: the adapter calls factory.createGraduatedPool(token) and then trades on the pool |
| 2, pool | a Uniswap V4 swap through PoolManager.unlock, ETH for token, with the Pons hook taking its fee after the swap |
| 3, rescued | reverts NoMarket: Pons cancelled the launch, nothing to buy |
It also exposes priceOf, progressOf and liquidityOf, which the site uses for the stats page.
Gas
Measured by the test suite on a fork of Robinhood Chain against the live Pons V2 (forge test -vv, 16 tests):
| Operation | Gas |
|---|---|
Deploy Zecpass |
1,699,504 (plus 1,637,366 for PonsMarket if deployed fresh) |
deliver, on the curve |
407,866 |
deliver, on the Uniswap V4 pool |
137,821 |
sweep, first time (deploys the pass) |
284,332 |
sweep, again, on the curve |
88,662 |
sweep, again, on the pool |
122,231 |
withdraw |
43,826 to 230,621 |
| the one buy that creates the V4 pool at graduation | about 1,050,000 |
At Robinhood Chain's base fee (about 0.09 gwei) a sweep costs well under a cent. The relayer pays it.
Deployment
Deployed on 2026-09-21 at 0x2d2F1e1c7B90272728a627605dD07FC46eF79232, owner and treasury 0xe568…E269, relayer 0x5C9A…951F, market 0xd039…5bA0, fee 50 bps, caps 0.5 ETH per order and 5 ETH per day.
forge script script/Deploy.s.sol:Deploy --rpc-url robinhood --private-key $PK \
--broadcast --slow --gas-estimate-multiplier 150 --legacy --with-gas-price 90000000
forge verify-contract <HUB> src/Zecpass.sol:Zecpass --chain-id 4663 \
--verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api
The flags are not optional on this chain: without --slow --legacy and the multiplier, the CREATE runs out of gas. After the token launches: cast send <HUB> "setToken(address)" <TOKEN> from the owner, then lockToken() once the address is confirmed on Pons.