# Pairpop security review

Pairpop is five contracts on Robinhood Chain (id 4663). All are Solidity 0.8.26, all are flat with no external dependencies, and none is a proxy or upgradeable. Every address below can be read straight off the chain.

| Contract | Address | Role |
| --- | --- | --- |
| `PairpopLauncher` | `0x6619CFb99421BB91d19837a6adF7e6Fd5c77666c` | Deploys a coin and records the pair |
| `PairpopOptions` | `0x3678890CA79Ea7bfD31a030469a5C565fb429033` | Writes, escrows and settles the option leg |
| `PairpopMarket` | `0xb70103a6F110D2249549006A8151717dDf32062A` | Order book for option tokens |
| `PairpopLogos` | `0xeD5F40A76538F37464802be812B7D4ed30e5c890` | Publishes artwork for a token |
| `PairpopReferenceFactory` | `0x49DF517F26415Fdde6bF72574cb789205843e3fB` | Deploys one reference ERC20 per ticker |

Two earlier launchers, `0x9C504EBB286560C5F2Ee1197950e806992487075` and `0xf4067664193a39a1B3564f5037f1B244D6a4861D`, still hold pairs created before the current one and are read by the board. Nothing is ever written to them again.

**Review dates:** 2026-09-08 (launcher), 2026-09-09 (options, market, logos, reference)
**Reviewer:** the team that built the contracts, reviewing its own work and publishing the result in full so it can be checked against the chain. No external firm has signed this.
**Suite totals:** 82 tests, 0 failures. Launcher 23 plus 1 fork test, Market 20, Options 18, Reference 12, Logos 8.

---

## PairpopLauncher

**Contract:** `PairpopLauncher.sol`, Solidity 0.8.26, no external dependencies, no proxy, no upgradeability
**Review date:** 2026-09-08

### Method

- Full manual line by line read of both contracts (`PairpopToken`, `PairpopLauncher`).
- Foundry suite: 24 tests, all passing, including a fork test against live chain 4663.
- Adversarial cases explicitly tested: hostile stock token that reverts, hostile token that burns unbounded gas, EOA passed as a stock token, fee recipient that rejects ETH, fee underpayment, supply bounds, empty strings, pagination overflow.
- Builds clean with no compiler warnings.

### Result

No critical, high or medium severity issues found. Findings below are low or informational.

### What the contract can and cannot do

**Cannot:**
- Mint, burn, pause, blacklist or freeze any launched token. `PairpopToken` has no owner and a supply fixed at construction.
- Touch a user's tokens or ETH after the launch transaction. The launcher holds no balance between calls: the fee is forwarded and any excess is refunded inside the same transaction.
- Claim a binding that does not exist. `stockBacked` is written by the chain after an on-chain probe of the underlying, never by the frontend.

**Owner can:** change the launch fee, change the fee recipient, disable new launches, transfer ownership. Nothing else. Owner powers do not reach existing pairs, existing tokens or user balances.

### Findings

**L-1 Fee change has no timelock (low).** The owner can raise `launchFee` at any time. A pending user transaction that sends exactly the old fee would then revert with `FeeTooLow`. Impact is a failed transaction and wasted gas, not a loss of funds, and any overpayment is refunded. Accepted.

**L-2 Reentrancy surface on fee transfer (low, not exploitable).** `launch()` follows checks effects interactions: all state is written and the token is deployed before the fee transfer and refund. A malicious `feeRecipient` reentering `launch()` gains nothing, since it would have to pay its own fee and the registry is already consistent. No reentrancy guard needed.

**I-1 `pairsBy(creator)` is unbounded (informational).** A creator with a very large number of pairs could make this view exceed the RPC gas cap. View only, no state risk. `recentPairs(offset, limit)` is the paginated path the site uses.

**I-2 Unbounded strings (informational).** Name, symbol and option metadata are caller supplied strings with no length cap. The caller pays the gas.

**I-3 No rescue function (informational, deliberate).** There is nothing to rescue: the contract's ETH balance is zero outside a transaction, and it never custodies tokens.

### Scope and limits

- It covers the launcher and the token it deploys. It does not cover the frontend, the RPC endpoint, or Robinhood Chain itself.
- No external firm has signed this review. Everything in it is stated precisely enough to be checked against the deployed bytecode.

---

## PairpopOptions

**Contract:** `PairpopOptions.sol`, 440 lines
**Tests:** 18 passing

### Method

Line by line read of all 440 lines, with attention to the four functions that move value (`writeCall`, `writePut`, `exerciseCall`, `exercisePut`) and the two that return it (`withdrawProceeds`, `reclaim`). Adversarial cases in the suite: fee on transfer underlying, underlying that reverts on transfer, EOA passed as an underlying, exercise after expiry, reclaim before expiry, double reclaim, partial exercise, exercise with the wrong ETH amount, and a writer contract that rejects ETH.

### Result

This is the contract that holds real value, so it got the hardest look. No critical or high issues. One medium was found and fixed during the build (balance delta accounting). The rest are low or informational.

### What the contract can and cannot do

- **Owner or admin role:** none. There is no owner variable and no access control beyond `NotWriter` checks that compare the caller against the recorded writer of a series.
- **Pause or upgrade:** neither exists. No pause flag, no proxy, no `delegatecall` anywhere.
- **Holds value:** yes, this is a real escrow. It holds the underlying ERC20 for covered calls and ETH for cash secured puts. Value leaves along exactly four paths: `exerciseCall` (underlying out to the holder, ETH in as strike proceeds), `exercisePut` (ETH out to the holder, underlying in), `withdrawProceeds` (proceeds out to the writer only), and `reclaim` (unexercised collateral out to the writer only, and only after expiry).
- **External calls:** a `decimals()` probe on the underlying, `transfer` and `transferFrom` on the underlying, `mint` and `burnFrom` on the option token it deploys, and raw `call{value:}` to send ETH.
- **Reentrancy:** guarded. A `nonReentrant` modifier backed by a single `_entered` slot wraps every state changing external function. Effects are also written before interactions, so the guard is a second line of defence rather than the only one.
- **Unbounded loops:** none in a state changing path. `recentSeries(offset, limit)` is paginated. `seriesByWriter` and `seriesByUnderlying` return whole arrays and are unbounded views.

### Findings

**M-1 Fee on transfer underlyings would have under collateralised a series (medium, fixed).** The first version credited `size` to the series and assumed the contract actually received `size`. A token that takes a transfer fee would have left the series short, so the last holder to exercise would have found the cupboard bare. Fixed: `writeCall` and `exercisePut` now measure `balanceOf(this)` before and after and credit only the delta received. Covered by a fee on transfer test.

**L-3 A writer that cannot receive ETH strands its own proceeds (low).** `withdrawProceeds`, and `reclaim` on a put, send ETH with a raw call and revert on failure. A writer contract with no payable fallback can never collect. State is zeroed before the send, so the revert rolls everything back and nothing is lost, but that writer is permanently unable to withdraw. Accepted: the writer chose the address, and a pull pattern would add a second transaction for every user in order to protect a self inflicted case.

**L-4 Strike is denominated per whole unit, so tiny amounts round to zero (low).** `_notional` computes `strikeWei * amount / 10 ** decimals`. Exercising an amount small enough that the product truncates to zero costs zero wei. The holder still burns their option tokens for it, so this is not free value, but a series with a very low strike and an 18 decimal underlying does allow dust exercise at no ETH cost. Accepted, and it is why the site enforces a minimum size at write time.

**L-5 Expiry is block timestamp based (low).** `reclaim` requires `block.timestamp >= expiry` and exercise requires the opposite. A validator can nudge the timestamp by seconds. Irrelevant at the day granularity the product uses. Accepted.

**I-4 No oracle, so exercise is voluntary (informational).** Nothing forces a rational exercise. An option that finishes in the money and is never exercised simply expires, and the writer reclaims. That is normal for physically settled options and is stated on the site.

**I-5 `seriesByWriter` and `seriesByUnderlying` are unbounded (informational).** Same shape as I-1 on the launcher. Views only, and the site reads them per underlying where the arrays are short.

### Scope and limits

- The escrow is only as sound as the underlying token. If the underlying freezes transfers, a covered call writer's collateral is frozen with it. Pairpop cannot fix that and does not vet underlyings.
- Nothing in this contract values an option. There is no pricing model on chain. Whatever a buyer pays on the market is what they decided to pay.

---

## PairpopMarket

**Contract:** `PairpopMarket.sol`, 212 lines
**Tests:** 20 passing

### Method

Full read, with the order of operations inside `buy` traced explicitly, since that one function moves a token and makes two ETH payments in a single call. Adversarial cases in the suite: seller that rejects ETH, buyer that rejects a refund, reentrant token, self fill, overfill, buying a cancelled listing, and exact payment with no refund.

### Result

No critical or high issues. One low finding on rounding, one on payout griefing, one on front running. All accepted with the reasoning below.

### What the contract can and cannot do

- **Owner or admin role:** none. No owner, no fee, no fee recipient. The contract takes nothing from a trade.
- **Pause or upgrade:** neither.
- **Holds value:** it escrows the listed option tokens for as long as a listing is active, and holds ETH for no longer than the duration of one `buy` call. The seller is paid and the buyer refunded inside the same transaction, so the resting ETH balance is zero. Tokens leave by `buy` (to the buyer) or `cancel` (back to the seller). There is no other path, and no address can drain a listing it did not create.
- **External calls:** `transfer`, `transferFrom` and `balanceOf` on the listed token, plus two raw `call{value:}` payments.
- **Reentrancy:** no modifier, but `buy` and `cancel` write every state change before any external call. `l.amount` is decremented and `l.active` cleared before `_push`, before the seller payment and before the refund. A reentrant token, or a reentrant seller re-entering `buy` on the same listing, finds the amount already reduced, so double spending one listing is not reachable. Covered by a reentrant token test.
- **Unbounded loops:** none in a state changing path. `recentListings` is paginated. `listingsByToken` and `listingsBySeller` are unbounded views.

### Findings

**L-6 Cost rounds down, so dust fills can be free (low).** `cost = amount * priceWeiPerUnit / 1e18`. For an amount small enough relative to the price this truncates to zero and the buyer receives tokens for no ETH. Extracting anything meaningful requires one transaction per dust slice, and gas on any slice exceeds the value taken, so it is not economically exploitable. Accepted rather than rounding up, which would overcharge honest fills.

**L-7 A seller that rejects ETH bricks its own listing (low).** If the seller payment reverts, `buy` reverts with `PayoutFailed`, so a contract seller with no payable fallback creates a listing nobody can fill. Only that seller is harmed, and `cancel` still returns the tokens. Accepted.

**L-8 Fills are first come, so they can be front run (low).** A pending `buy` on an attractive listing can be seen in the mempool and beaten by a higher priority transaction. The loser's transaction reverts with `ListingClosed` or `NotEnoughListed` and they lose gas, nothing more. This is inherent to an on chain order book without commit reveal, and commit reveal would cost every honest fill more than this costs the occasional loser. Accepted and disclosed.

**I-6 A listing can outlive the option it sells (informational).** Nothing links a listing to the expiry of the series behind the token. A listing for an expired, worthless option can sit open and be filled by someone who did not check. The site marks expired series; the contract will not stop the trade.

**I-7 Any ERC20 can be listed (informational).** The market does not check that a listed token came from `PairpopOptions`. That keeps it unopinionated, and it means the token address on a listing is the only thing identifying what is being sold. The site resolves it against the vault before rendering.

### Scope and limits

- There is no matching engine and no price discovery. This is a list of resting offers, nothing more.
- Liquidity is whatever people have listed. A book with few resting offers means a wide spread, and the card shows the ask so the spread is visible before anyone trades.

### Listing at launch (added 2026-09-09)

The launch form can now list part of a new coin's supply immediately after deploy. This added no contract code: it calls `approve` then `list` on the market already reviewed above, from the creator's own wallet. What is worth recording is the flow, since it is where a user is most likely to be surprised.

- **Approval is exact, not infinite.** `listForSale` approves precisely the amount being listed, and only if the current allowance is short. A user who lists twice signs two approvals rather than leaving a standing infinite allowance on their token. Slightly worse UX, materially smaller blast radius if the market is ever compromised.
- **The listable amount is supply minus vault escrow.** Tokens locked against the option leg are genuinely gone from the creator's balance, so validating against total supply would produce a transaction that reverts inside `transferFrom`. Both the live math and the submit check use the free balance, and `listForSale` re-reads `balanceOf` on chain before signing anything.
- **Listing runs last and fails soft.** The coin and the option are final before the listing is attempted. A rejected approval or a reverted `list` leaves the creator holding the supply and the success screen says so explicitly. No path exists where a failed listing costs the user their launch.
- **The market is checked for liveness, not assumed.** If `marketIsLive()` is false the entire section is hidden rather than offering something that would fail.

**I-8 A creator can list at any price, including an absurd one (informational).** Nothing constrains `priceWeiPerUnit`. A coin can arrive with an ask so high it is effectively unbuyable, which looks like liquidity on the board but is not. The card shows the ask, so the price is visible; the contract will not judge it.

**Coverage note.** The listing path is verified by selector against the deployed contract, by the 20 passing market tests, and against the deployed bytecode. At the time of writing no launch has yet used it end to end in production.

---

## PairpopLogos

**Contract:** `PairpopLogos.sol`, 44 lines
**Tests:** 8 passing

### Method

Full read. The contract is 44 lines and has no storage at all, so the review is mostly about what the absence of storage implies for whoever reads its events.

### Result

No issues in the contract. The one thing worth understanding is the trust model, which lives off chain by design.

### What the contract can and cannot do

- **Owner or admin role:** none, and deliberately no permission of any kind. Anyone can call `setLogo` for any token address.
- **Pause or upgrade:** neither.
- **Holds value:** nothing. No payable function, no token handling, no storage variables. It is an event emitter.
- **External calls:** none whatsoever. It never calls another contract.
- **Reentrancy:** not applicable. No state, no external call.
- **Unbounded loops:** none. The only bound is `MAX_URI_BYTES`, 32768, on the payload length.

### Findings

**L-9 Anyone can publish artwork for any token (low, by design).** `setLogo(token, uri)` does not check that the caller created the token. Two addresses can publish two different images for the same coin, and an impersonator can publish a misleading one. This is handled where it belongs, at read time: the site reads `LogoSet` logs and accepts only the most recent event whose `setter` equals the token creator recorded by the launcher. An impersonator's event is on chain but never rendered. An on chain permission check would have forced this registry to trust the launcher, which is worse coupling than filtering on read.

**I-8 Calldata is permanent and public (informational).** Artwork lives as event data in chain history, so a published image cannot truly be deleted. `clearLogo` emits a withdrawal event that the reader honours, but the original bytes remain in the logs forever. Anyone uploading artwork should understand that.

**I-9 32 KB is a lot of calldata (informational).** A maximum size logo is an expensive transaction. The site compresses to WebP and shows the cost before publishing.

### Scope and limits

- Trust in this registry is entirely a frontend convention. A different frontend reading the same events could show the impersonator's image, and nothing on chain would stop it.

---

## PairpopReferenceFactory and PairpopReferenceToken

**Contract:** `PairpopReference.sol`, 162 lines
**Tests:** 12 passing
**Deployed:** 51 reference tokens across 4 batches, symbols `ppAAPL`, `ppAMD` and so on

### Method

Full read of both the token and the factory, including the EIP-1167 clone assembly, which was compared against the reference minimal proxy bytecode. Adversarial cases in the suite: duplicate ticker, empty ticker, ticker over 8 bytes, double initialisation of a clone, initialisation of the implementation itself, transfer beyond balance, and `transferFrom` without allowance.

### Result

No critical, high or medium issues. The important content of this section is not the bug list, it is the disclosure in I-10.

### What the contracts can and cannot do

- **Owner or admin role:** none on either. The factory has an `immutable implementation` and no owner. Each token records an `issuer` address for display only, and `issuer` grants no powers: no function anywhere reads it for authorisation.
- **Pause or upgrade:** neither. Clones point at a fixed implementation set in the factory constructor, and that address is `immutable`, so it can never be repointed.
- **Holds value:** the factory holds nothing and has no payable function. Each token's whole supply is minted once to the `holder` passed at deployment. There is no mint function afterwards, no burn, and no owner withdrawal.
- **External calls:** the factory calls `initialise` on the clone it just created. The token makes no external calls at all.
- **Reentrancy:** no guard and none needed. The token moves no ETH and calls nothing. The factory's only external call goes to a clone of a known implementation, made after the ticker is already reserved.
- **Unbounded loops:** `deployBatch` loops over the caller supplied `tickers_` array with no cap. The caller pays the gas, and a batch too large simply runs out of gas and reverts. The 51 live tokens were deployed in 4 batches for exactly this reason.

### Findings

**I-10 Reference tokens are Pairpop issued (informational, and the most important line in this document).** `ppAAPL` is an ERC20 Pairpop deployed on Robinhood Chain so a pair has a real token to bind to and settle against. It is not Apple stock and carries no claim on any share, dividend or asset, and there is no redemption path. The contract states this on chain itself: `disclosure()` returns that sentence in plain text, so no frontend can strip it. `NVDA` and `SPY` are the two underlyings on this chain whose tokens Pairpop did not issue.

**L-10 Ticker registration is first come and permanent (low).** `_deploy` reverts with `ticker taken` if the key already exists, and there is no way to reassign one. Anyone can call `deployBatch` and claim an unclaimed ticker, and a squatter's token would be indistinguishable on chain from ours. Mitigated by reading `tokenForTicker` only through the deployed factory address the site hardcodes, and by the fact that a squatted ticker competes only with a reference token, not with anything holding value. Accepted rather than adding an owner, since an owner is a worse trust assumption than a squatting risk.

**L-11 A ticker is capped at 8 bytes with no character validation (low).** `_deploy` checks length only. A ticker of spaces, or one using homoglyphs, is accepted and would render as a near duplicate of a real one. The site only ever calls the factory with tickers from its own list, so this is reachable only by someone bypassing the site. Accepted.

**I-11 Clones have no EIP-1167 registry check on read (informational).** Anyone can deploy a contract that mimics the reference token interface. Verification is by address: the site trusts only addresses returned by `lookup(ticker)` on the deployed factory, never a token that merely claims a name.

### Scope and limits

- These tokens exist to make the binding real. Holding `ppAAPL` is exposure to `ppAAPL` itself, not to the company behind the ticker.
- The supply is fixed at deployment and there is no float beyond what people trade, so any price is whatever a buyer and a seller agree on.

---

## Check it yourself

Every claim above is about deployed bytecode, so the chain is the authority, not this document. With any RPC pointed at Robinhood Chain (id 4663):

```
RPC=https://rpc.mainnet.chain.robinhood.com

# the launcher is owned by one address and takes a fixed fee
cast call 0x6619CFb99421BB91d19837a6adF7e6Fd5c77666c "owner()(address)"        --rpc-url $RPC
cast call 0x6619CFb99421BB91d19837a6adF7e6Fd5c77666c "launchFee()(uint256)"    --rpc-url $RPC

# the market has no owner and no fee: these calls have nothing to return
cast code 0xb70103a6F110D2249549006A8151717dDf32062A --rpc-url $RPC

# a reference token states what it is, on chain, in plain text
cast call <referenceToken> "disclosure()(string)" --rpc-url $RPC
```

The [proof page](/proof) runs the same reads live in the browser and prints what comes back, so nothing on it is typed by hand.
