Part 2 of "Building on Polkadot Hub: A Builder's Journal"
TL;DR
Every token on plaza.fun starts on a bonding curve — a constant-product AMM that prices it from the first trade. One contract call creates a pallet_assets native asset, and a two-phase creation process bridges EVM business logic with Substrate's runtime asset system. When the curve fills, graduation triggers automatically.
What Happens When You Click "Launch"
A creator pays 6.28 DOT and provides a name, symbol, and description. Behind the scenes, a single contract call kicks off a process that spans two execution layers.
Here's the challenge: the bonding curve logic lives in Solidity on the EVM side, but the token itself needs to be a pallet_assets native asset on the Substrate side. An EVM contract can't directly call assets.create() — that's a runtime pallet, not a contract function. So we split creation into two phases.
Phase 1: The EVM Side
The user's transaction hits OmniBondingCurve. The contract:
- Validates inputs and checks the creation fee against
QuoteTokenRegistry(fees are configured per quote token) - Assigns a
tokenIdand initializes the curve's state — virtual reserves, the constant productk, creator address - Escrows all funds: creation fee, ignition amount, and any initial buy
- Sets the token status to Pending
- Emits
TokenCreationRequested
At this point, the token exists as a data structure in the EVM contract, but there's no actual asset yet. The money is locked. Nothing trades.
Phase 2: The Substrate Side
A Relayer watches for TokenCreationRequested events and executes a batch of Substrate extrinsics:
assets.create() — creates the pallet_assets asset assets.setMetadata() — name, symbol, decimals assets.touchOther() — opens a balance record for the contract's mapped account assets.mint() — mints 1 billion tokens to the contract
That touchOther call is easy to miss and hard to debug. pallet_assets requires every account to have a balance record before it can receive tokens. The bonding curve contract is an EVM address mapped to a Substrate AccountId32 — it can't "open an account" for itself. Without touchOther, the mint fails silently.
Once the Substrate transaction confirms, the Relayer calls confirmTokenCreation() back on the EVM side. This activates the token, distributes the creation fee, executes any ignition (a burn that contributes to graduation liquidity), and runs the creator's initial buy — all in one transaction.
After confirmation, the Relayer immediately renounces all Substrate-level permissions. The token is permissionless from birth: no one can freeze it, mint more, or modify its metadata.
The Pricing Formula
The bonding curve uses constant-product pricing — the same x * y = k formula as Uniswap, but with virtual reserves to set a non-zero starting price.
Virtual Token Reserve: 1,073,000,000 Real Token Reserve: 793,100,000 (79.31% — what's actually for sale) Virtual Quote Reserve: 2,198 DOT (700π) k = virtualToken × virtualQuote
The "virtual" reserves are a math trick. They give the curve a reasonable starting price without requiring any real liquidity upfront. As people buy, the real token reserve decreases and the price rises along the curve. When all 793.1M tokens sell, the curve has accumulated 6,280 DOT and graduation triggers.
Why 6,280? That's 2,000π. The creation fee is 2π DOT. The graduation threshold is 2,000π. From 2π to 2,000π — a thousandfold path. Every core parameter is a multiple of π. It started as a joke. Then it became a brand.
Safety Mechanisms
A bonding curve that holds real money needs more than just math:
Anti-sandwich protection — Each block has a volume cap: 15% of the graduation threshold. A single block can't move the price more than that. It doesn't stop cross-block MEV, but it caps the damage from same-block sandwich attacks.
Max buy (2%) — No single purchase can acquire more than 2% of the total supply. Prevents whale dominance on the curve.
Cooldown blocks — Same user, same token, one trade per N blocks. Rate limits rapid-fire trading.
Cap refund — If a buy would exceed the remaining token supply, the contract calculates the exact amount needed, executes a partial fill, and refunds the rest — including recalculated fees.
Push-pull fees — Fee distribution uses try-push-then-queue. If a fee recipient's address reverts on transfer (malicious or misconfigured), fees queue up for manual withdrawal instead of blocking the trade.
Graduation failsafe — Graduation is attempted via a self-call with try/catch. If it fails (e.g., due to a gas issue), trading continues and graduation retries on the next relevant trade. An admin forceGraduate() function exists as a last resort.
What We Learned
The two-phase creation is the biggest complexity cost in the system. Every edge case doubles: what if the Relayer is down? What if confirmation fails? What if the user overpays? We built timeout recovery (creators can reclaim funds after 2 hours if the Relayer doesn't confirm) and failure handling (Relayer can explicitly fail a creation and trigger refunds). It works, but it's the part of the codebase with the most defensive code.
Virtual reserves are powerful but unintuitive. The gap between virtualTokenReserve (1.073B) and realTokenReserve (793.1M) confused us during testing more than once. The extra ~280M virtual tokens exist purely to set the initial price point. They're never sold, never transferred, never minted. They're just math.
pallet_revive has a hidden ceiling for complex calls. Our graduation flow — 4+ nested external calls, a CREATE2 deployment, native value transfers, and multiple precompile interactions — works perfectly via EVM RPC but consistently reverts via pallet_revive.call. Same token, same state, same parameters. We isolated every component individually: deep nesting passes, CREATE2 passes, precompile calls pass, large value transfers pass. Only the full combination fails. The likely cause is a per-transaction weight limit (~6M EVM gas equivalent) that the combined operation exceeds under pallet_revive's gas metering model, even though the actual EVM gas is only ~889K. We filed a detailed report and redesigned graduation as an async two-step process: buy() marks "graduation pending", then a permissionless executeGraduation() handles the heavy lifting. It adds ~3 seconds of delay, but it works on both EVM and Polkadot native wallets. Any DeFi protocol on Hub with multi-contract flows will likely need a similar pattern — until the platform-level limit is addressed.
Precompile interactions are the fragile layer. Our contracts call ERC20 precompile functions to move native assets. That mostly works — until it doesn't. transfer() and approve() are fine. name() and symbol() panic. We hit this in production and had to build workarounds. Part 5 covers the full story.
Next: Part 3 — The Fee Engine — where the 1% goes and why creator earnings are split across two different mechanisms.
Previous: Part 1 — Why We Built on Polkadot Hub
Follow @plaza_fun for updates. · Discord
