Security & Governance

Part 5: The attack surface map for a cross-layer dApp, real bugs we found, and the governance architecture — TimelockController, Guardian, and immutable components.

Cover Image for Security & Governance

Part 5 of "Building on Polkadot Hub: A Builder's Journal"


TL;DR

We ran multiple rounds of security review on plaza.fun — 76 findings across 15 audit iterations, 57 fixed so far. The bugs we found weren't just Solidity mistakes. The hardest issues lived at the boundary between EVM contracts and Substrate's runtime. This post maps the attack surface, walks through the real bugs, and explains the governance architecture we built to make the whole system verifiable on-chain.


The Attack Surface Map

plaza.fun is a cross-layer system. That means the attack surface has five layers, not one:

Layer 5: Frontend / Wallet Interaction
         ├── MetaMask vs Polkadot wallet routing
         └── Address format confusion (H160 vs SS58)

Layer 4: Indexer / Relayer
         ├── Relayer as single point of failure
         └── Keeper bot MEV exposure

Layer 3: EVM ↔ Substrate Bridge (Precompiles)
         ├── ERC-20 precompile behavior gaps
         ├── approve() cross-contract failures
         └── Decimal mismatch (18 EVM vs 10 Substrate)

Layer 2: EVM Smart Contracts
         ├── Standard Solidity vulnerabilities
         ├── UUPS upgrade safety
         └── Fee distribution DoS vectors

Layer 1: Substrate Runtime (pallet_assets)
         ├── Asset ownership and permissions
         └── Existential deposit edge cases

Most DeFi audits only cover Layer 2. On Hub, the interesting bugs are in Layers 3-5.

What We Found: Real Bugs

Critical: LP Harvest Sandwich Attack

The CreatorFeeVault's harvestLPFees() function calculates profit using spot reserves and executes a swap — both readable and manipulable by MEV bots.

The attack: A bot watches the mempool for harvest transactions. Before harvest executes, the bot buys the meme token to inflate reserves. The harvest function sees inflated profit, removes more LP than it should, and swaps at a bad rate. The bot sells after, pocketing the difference.

The fix: The keeper now provides three parameters calculated off-chain: maxLPToHarvest (caps LP removal), minWdotOut (minimum acceptable output), and deadline (expiration). The contract enforces all three. A sandwich bot can still inflate the price, but the harvest will revert if the output falls below the keeper's floor.

High: Phantom Profit from Baseline Drift

After each harvest, the baseline value (initialLPWdotValue) was updated using proportional reduction: newBaseline = oldBaseline × remainingLP / totalLP. The math is correct for normal appreciation. But it creates a subtle bug: even when the price hasn't moved, the baseline drifts downward with each harvest, creating "phantom profit" that can be harvested again.

Impact: Over time, repeated harvests would slowly drain the locked LP even without real fee growth. The 5% per-harvest cap slows it down but doesn't prevent it.

The fix: After harvest, reset the baseline to the actual current value of the remaining LP, rather than scaling it proportionally.

High: Pre-seeded Pair Manipulation

An attacker could call factory.createPair() before a token graduates and seed it with a tiny amount of liquidity at a manipulated price. When graduation adds the real liquidity, the Uniswap V2 math would route a portion of the tokens to the attacker's existing LP position.

The fix: The DirectAdapter now checks pair reserves before graduation. If reserves exist but are below a minimum threshold, it treats the pair as new. If reserves are significant, it validates that the pair price is within 5% of the expected graduation price. Deviation beyond that reverts the graduation.

High: Adapter Slippage Gap

The DirectAdapter had no minimum check on minted LP tokens. A manipulated pair could cause graduation to receive less LP than expected, permanently reducing the burned and locked amounts.

The fix: After pair.mint(), validate that the returned LP is at least 95% of the theoretical minimum: sqrt(tokenAmount × quoteAmount).

The Precompile Panic

This one isn't a contract bug — it's a platform behavior. pallet_assets ERC-20 precompiles on Hub support transfer(), approve(), balanceOf(), and totalSupply(). But calling name(), symbol(), or decimals() triggers a revert with panic code 0x41 (resource error).

MetaMask calls these metadata functions to identify token type. When they fail, MetaMask concludes "this isn't an ERC-20" and displays the token as an NFT. Users see "Approve NFT withdrawal?" when trying to trade a fungible token.

MetaMask → eth_getCode ✅ → name() ❌ → "not an ERC-20" → displays as NFT

Our workaround: Custom frontend components that read token metadata from the database instead of the chain. useReadContract instead of useBalance to avoid internal decimals() calls.

The real fix: We reported this and contributed to a PR adding IERC20Metadata support to the precompile. Merged, awaiting the next Hub runtime upgrade.

Cross-Layer Bug Patterns

Three patterns kept appearing:

1. What works in EVM doesn't work through precompiles. approve() in a direct call works fine. approve() called from another contract through the ERC-20 precompile silently fails on Hub. This broke the standard Uniswap V2 Router flow and forced us to build the DirectAdapter.

2. Decimal mismatches create silent accounting errors. EVM uses 18 decimals. pallet_assets uses 10 (configurable, but 10 is the Hub default for DOT). Any calculation that doesn't convert correctly produces values off by 10^8. We caught several of these in testing, but they're easy to miss because the transactions succeed — they just move the wrong amounts.

3. Existential deposit is a hidden constraint. Substrate accounts need a minimum balance (existential deposit) to stay alive. If a transfer would drop the sender below ED, it fails. EVM contracts don't have this concept. Every token transfer in our system checks ED before executing — a defensive pattern that doesn't exist in standard EVM development.

Governance Architecture

Finding bugs and fixing them is necessary. But what happens after launch? How does the community verify that we don't introduce new ones through upgrades?

The Platform Is Evolving Too

We're not building on a static chain. Polkadot itself is going through its most significant economic reform in years — through its own on-chain governance. In March and April 2026 alone: DOT issuance dropped from 120M to ~55M annually, validators now need 10,000 DOT minimum self-stake, nominators are becoming unslashable, and the unbonding period is dropping from 28 days to under 48 hours.

All of this happened through OpenGov referenda — not a core team decree. The same governance system that upgrades the chain's economics is the one we're building our own upgrade path around. That's why we take on-chain verifiability seriously: if Polkadot can change its own monetary policy transparently, a meme platform has no excuse for opaque upgrades.

TimelockController

Every upgradeable contract (8 total) is owned by an OpenZeppelin TimelockController. Upgrades follow a three-step process:

  1. Schedule — The proposer submits the upgrade transaction to the Timelock. It's visible on-chain immediately.
  2. Wait — A minimum delay must pass. During this time, anyone can inspect the pending upgrade — read the new implementation code on Blockscout, verify it doesn't contain malicious changes.
  3. Execute — After the delay, the upgrade can be executed.

No shortcuts. No emergency override for upgrades. If we push a bad upgrade, there's a window for the community to notice and raise an alarm before it takes effect.

Guardian: Emergency Pause

For incidents that need immediate response — a critical bug discovered in production, an ongoing exploit — the Guardian role can pause the bonding curve instantly. Pause stops all trading; it doesn't upgrade contracts or move funds.

Slow governance for upgrades. Fast response for emergencies. Two separate roles, two separate keys, two separate threat models.

Immutable Components

Not everything needs to be upgradeable. The treasury fee collector is deployed as an immutable contract — no upgrade path, no treasury setter, no arbitrary call, no rescueToken() function. PlazaSwap Factory.feeTo funds flow from pairs to the configured treasury, and FeeKeyNFT earnings stay limited to locked-LP harvests.

FeeKeyNFT and the DirectAdapter have renounceOwnership explicitly disabled — calling it reverts. This prevents accidental or malicious ownership renunciation that could brick the contracts.

What This Means for Verification

Any Blockscout user can:

  • See all pending Timelock operations (scheduled upgrades)
  • Read the new implementation code before it executes
  • Verify that the ProtocolFeeCollector has no owner and no upgrade function
  • Confirm that 90% of LP tokens went to the burn address
  • Check that FeeKeyNFT ownership can't be renounced

The trust model is: don't trust us, verify the contracts.

Audit Checklist for Hub dApps

If you're building on Hub, here's what we'd add to a standard EVM audit:

  • [ ] Test every ERC-20 precompile function you use — name(), symbol(), decimals() may not work
  • [ ] Verify approve() works in your cross-contract call patterns
  • [ ] Check decimal conversion at every EVM ↔ Substrate boundary
  • [ ] Account for existential deposit in all transfer logic
  • [ ] Test with both MetaMask and Polkadot wallets — they hit different code paths
  • [ ] Audit your Relayer/keeper — it's likely a single point of failure
  • [ ] MEV-protect any function that reads spot prices and executes swaps
  • [ ] If using UUPS proxies, verify storage layout compatibility across all upgrades
  • [ ] Test pallet_assets permissions lifecycle: create → mint → renounce
  • [ ] Verify your governance model covers both upgrades and emergencies

What We Learned

Contract size limits shape your architecture — but check your compilation path first. The ~24KB bytecode limit hit us hard when compiling with resolc (the PolkaVM compiler), which produces 10-20x larger bytecode than standard solc. This forced us to split our main contract into 5 external libraries and a separate GraduationManager — going from 27KB to 16KB across multiple refactoring rounds. More contracts means more cross-contract calls, which means higher gas costs and a wider attack surface. We filed a feature request to improve the developer experience around this limit.

Late discovery: Polkadot Hub supports a dual VM architecture. REVM runs standard EVM bytecode — no resolc, no bytecode bloat. Our largest contract compiles to 24,244 bytes under standard solc, well within the 24KB limit. The official docs now recommend starting with REVM for most use cases, with PVM reserved for computationally intensive workloads. We're evaluating REVM for our mainnet deployment. The workarounds we built (async graduation, DirectAdapter) still work — they're just no longer necessary. More on this in Part 6.

Audit the boundaries, not just the contracts. Our most impactful bugs lived at the EVM-Substrate interface, in the Relayer logic, and in economic assumptions about LP behavior. A pure Solidity audit would have missed all of them.

Design governance from Day 1. We added TimelockController after the core contracts were done. It would have been cleaner to design ownership transfer into the deployment scripts from the start. Retrofitting governance onto existing contracts works but requires careful verification of every ownership path.

Immutable where possible, upgradeable where necessary. Every upgradeable contract is a contract that can be changed for the worse. We made the ProtocolFeeCollector immutable because its job is simple and unlikely to need changes. If we could go back, we'd make more components immutable and fewer upgradeable.


Next: Part 6 — Lessons & What's Next — what we'd do differently, what surprised us, and where we're going.

Previous: Part 4 — The Token Lifecycle

If you're an auditor or security researcher who's worked with Polkadot Hub contracts, we'd genuinely like to compare notes — Forum thread here.

Follow @plaza_fun for updates. · Discord