Zyora, a token launchpad on Arc
I wrote the Solidity contracts, the chain indexer and the launchpad API for a token launchpad on Circle's Arc chain: 304 tests, 47 launches, and launch liquidity that no wallet can withdraw.
- Status
- Live on Arc mainnet
- When
- Sep 2026
- Role
- Wrote the contracts, the chain indexer and the launchpad API
- Team
- Contracts solo; two other engineers on the app
- Stack
- Solidity, Foundry, Solady, TypeScript, Next.js, Cloudflare Workers, PostgreSQL
- Links
- zyora.fun
- 3041
- test functions: 260 unit and integration, 29 fuzz, 15 invariants
- 472
- tokens launched on Arc mainnet by 16 Oct 2026
- 279 B3
- headroom under the 24,576-byte contract size limit
- 1%2
- maximum swap fee, fixed as a constant in the router
Summary
Zyora is a token launchpad on Arc, Circle's chain where USDC is both the gas token and the asset every launch trades against. I wrote its Solidity contracts in Foundry: the bonding-curve launchpad, a constant-product AMM, a swap router, the liquidity locker and a launcher for NFT collections. The curve follows the economics of Meteora's Dynamic Bonding Curve, ported to the EVM, and the AMM adapts Uniswap V2's design. In the app I built the chain indexer and the launchpad API that the site reads from.
It went live on Arc mainnet on 11 September 2026. By 16 October, 47 tokens had launched through it2, and the liquidity behind 44 of them sits in locks that no wallet can withdraw, including mine.
Constraints
- No DEX to graduate into. Arc had no audited AMM a launch could hand its liquidity to, so the AMM had to ship in the same repository.
- No upgrades. The contracts are not proxies. Every fix is a redeploy, and tokens that already launched have to keep trading through it.
- A hard size limit. EIP-170 caps deployed code at 24,576 bytes, and the launchpad holds the curve, buys and sells, fee splits, graduation and claims.
- An unusual native token. USDC has 6 decimals as an ERC-20 and 18 as the gas token, and public RPCs throttle and cap a log query at 2,000 results.
Decisions
Liquidity that cannot be pulled
The docs promised that launch liquidity was locked for good, but the first version named the creator as the beneficiary of each lock, so after 30 days a creator could withdraw the pool's liquidity and drain it. I made the launching contract the beneficiary instead. It has no code path that withdraws, so the lock is permanent by construction rather than by policy. Three tokens launched before the fix; their locks still belong to their creators2.
Limits written into the bytecode
The swap router routes trades through outside venues and takes a platform fee. The fee cap is a constant, MAX_FEE_BPS = 100, so no owner key can raise it above 1%. Each call approves exactly the amount it forwards, resets the approval to zero in the same call and checks the output by balance difference rather than trusting the venue's return value.
Fitting under 24,576 bytes
The launchpad compiles at 24,297 bytes, 279 under the limit3. Getting there meant compiling that one contract for size, moving the NFT collection's creation code into its own deployer and splitting the standard launch into a second contract, because both launch mechanisms did not fit in one.
An opening price that costs nothing to fund
A standard launch opens at a 6,000 USDC market cap through a virtual quote reserve that is never paid out. Any swap that would pay out more than the pool really holds reverts, and liquidity burns are capped at real holdings, so the virtual reserve can set the price but can never be withdrawn.
Where the bytes went
| Contract | Bar | Bytes |
|---|---|---|
ZyoraLaunchpad | 24,297 | |
ZyoraStandardLauncher | 17,271 | |
ZyoraCollectionDeployer | 15,346 | |
AmmFactory | 13,001 | |
ZyoraRegistry | 10,764 | |
AmmPair | 9,482 | |
ZyoraCollectionToken | 8,205 | |
AmmRouter | 4,297 | |
ZyoraCollectionMirror | 3,857 | |
ZyoraSwapRouter | 2,785 | |
LiquidityLocker | 2,609 | |
| The vertical line marks the EIP-170 limit, 24,576 bytes. | ||
The hard part: redeploying without stranding anyone
Standard launches failed on mainnet with no revert reason. The factory embeds the pair's creation code at compile time, so the deployed factory kept creating pairs from before setVirtualQuote existed, and every launch call reverted inside them. Redeploying the launchers alone could not fix that.
I redeployed the factory, the router and both launchers, kept the old pairs trading on the old router, and taught the app to look up which factory holds each pair. The upgrade script also missed a call to setRouter, and pairs only accept swaps from factory.router(), so every swap through the new router would have reverted. Both fixes went out the same day, and the new factory's router reads back correctly on chain2.
The indexer and API
The app runs on Cloudflare Workers with Neon Postgres. In production every API route alternated between a response and error 1101, and the indexer never caught up. The cause was a Postgres pool kept alive across requests: Workers do not let one request use a socket opened by another. Each query now opens and closes its own connection, and no request awaits a promise or shares an RPC client created by another.
The indexer moved to a GitHub Actions job that runs every five minutes, resumes from a saved cursor and stops within a time budget. Log windows are sized to Arc's 2,000-result cap and split when a node asks for smaller ranges, inserts are batched, and RPC calls fall back through an ordered list of providers. I wrote most of the indexer and the API's read and write paths; a teammate wrote the indexer for outside venues and the charts.
Verification
- 304 test functions in 25 files: 260 unit and integration tests, 29 fuzz tests at 4,096 runs each and 15 invariants run as 384 sequences of 192 calls, where any revert fails the run1.
- The last green CI run, on 16 September 2026, ran 318 tests because two suites run twice through inheritance1. CI also checks formatting, contract sizes and ABI drift.
- Slither across 102 detectors, with a written disposition for each of its 26 findings.
- A script that rehearses the launch lifecycle read-only against Arc mainnet's real USDC, using
eth_callstate overrides.
| Kind | Bar | Functions |
|---|---|---|
| Unit and integration | 260 | |
| Fuzz | 29 | |
| Invariant | 15 |
Deployment register
Current contracts on Arc mainnet, chain ID 50424. Earlier launchpads and the first AMM factory are still indexed so their tokens keep trading.
| Contract | Address | Deployed |
|---|---|---|
ZyoraRegistry | 0xf2Bc89A82485770887645046704a20dBA5195937 | 11 Sep 2026 |
ZyoraLaunchpad | 0x0351FC87f7F4bFb73a263559Ce5Cb570800Ab6cf | 16 Sep 2026 |
ZyoraStandardLauncher | 0x781fb303EB07d7b7B472865DD50019F62f374d3B | 16 Sep 2026 |
ZyoraSwapRouter | 0xb3a18a472FBb57F0F4FFFe83900e511675c26710 | 11 Sep 2026 |
AmmFactory | 0x1f0740C83E9acC8bc2a83cdE7a42e58F018C817C | 16 Sep 2026 |
AmmRouter | 0x9d754eAAC30c454D218d9c87efdE15Add3793C9e | 16 Sep 2026 |
LiquidityLocker | 0xEe0Ea91c7c1bc7F77ADCe09592EAE1ED59D30769 | 11 Sep 2026 |
What I would change
Both mainnet bugs were wiring mistakes that a review catches only some of the time. Next time the deploy scripts end with assertions that read the chain back, such as the factory's router, every launcher's registration and the fee constant, so a deploy that leaves the system half-wired fails on the spot instead of waiting for a careful reader.
Sources
- 1.Test functions counted in the zyora-program sources; CI run of 16 Sep 2026. Private repository, counted on 16 Oct 2026.
- 2.Read-only calls to Arc mainnet and zyora.fun/api/stats. MAX_FEE_BPS, launch and lock counts, and the factory's router, read on 16 Oct 2026.
- 3.forge build --sizes in CI, 16 Sep 2026. Matches the runtime code length on chain.
- 4.Foundry broadcast receipts and the app's production config. All receipts have status 1.