You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
7ce465b — HEAD of playground as of 2026-08-16. First verified at 8d3e280; re-verified after GH#2519 (confirmation checks only — registry logic untouched)
POST /api/devnet-register-mint writes rows into devnet_mints — the registry that /api/devnet-airdrop and /api/devnet-pre-fund consult — after checking nothing but the base58 shape of the submitted address. Anyone can insert rows naming any mainnet CA with attacker-chosen symbol and decimals, at 20 rows/hour per serverless instance, and those rows can never be corrected through the API (ignoreDuplicates: true). Today this pollutes a registry that two live routes trust for authorization and amount math; if /api/devnet-mirror-mint is ever re-wired to a frontend flow, the same rows permanently break mirror creation for every squatted CA.
1. What the route does
The route exists to record a devnet mint in the devnet_mints mapping table (mainnet_ca → devnet_mint, plus name, symbol, decimals). Its entire validation posture is:
// app/app/api/devnet-register-mint/route.tstry{newPublicKey(mintAddress);}catch{ ... 400}// lines 82-86 — the ONLY check
...
const{ error }=awaitsupabase.from("devnet_mints").upsert({mainnet_ca: mintAddress,// attacker-chosen registry keydevnet_mint: mintAddress,// the "devnet mint" IS the (mainnet!) address itselfname: safeName,symbol: rawSymbol,decimals: safeDecimals,},{onConflict: "mainnet_ca",ignoreDuplicates: true});// lines 112-121 — first writer wins forever
Never verified: that the address exists on devnet, that it is an SPL mint, or that its mint authority is the server keypair — a check this codebase already ships in app/app/api/devnet-pre-fund/route.ts:402-429 (getMint() + authority comparison).
Unrepairable through the API — ignoreDuplicates: true (line 120) means a squatted mainnet_ca row can never be corrected by re-registration; cleanup requires direct DB access.
Attacker-chosen decimals feeds consumer math — /api/devnet-airdrop reads decimals from devnet_mints rows (route.ts:491-503) and scales grant amounts with 10 ** decimals; the stored value is accepted verbatim (any integer 0-18).
The registry feeds an allowlist — /api/devnet-pre-fund treats a devnet_mints row as "permitted" (route.ts:190-215); for a squatted CA the row's devnet_mint equals the mainnet address, so the DB check passes and only the downstream on-chain getMint authority check stops the mint. The allowlist is being fed unverified data.
3. The latent poisoning chain (why severity is Low, not Medium)
If /api/devnet-mirror-mint is ever called with a squatted CA — by a future wizard re-wiring, old runbook docs, or any new consumer — it consults the registry before creating a mirror and returns early on any hit:
// app/app/api/devnet-mirror-mint/route.ts:248-260constexisting=supabase
? (awaitsupabase.from("devnet_mints").select("devnet_mint, name, symbol, decimals, logo_url").eq("mainnet_ca",mainnetCA).maybeSingle()).data
: null;if(existing?.devnet_mint){returnNextResponse.json({status: "existing",devnetMint: existing.devnet_mint,// ← for a squatted row: the MAINNET address itself
...
});}
The caller is told a devnet mint exists; none does. Every later on-chain step fails far from the cause, and both repair paths refuse (ignoreDuplicates here at line 120 and in mirror-mint's own upsert at line 392) — the mapping is broken permanently until manual DB cleanup.
Why latent: the current wizard no longer calls devnet-mirror-mint at all — the mirror-collateral model was removed (CreateMarketWizard.tsx:330-342: "devnet collateral is ALWAYS Sim-USDC … skip it for every devnet market"), and the post-launch airdrop goes through /api/devnet-airdrop (useCreateMarket.ts:3342-3346). A repo-wide search finds no runtime frontend caller for devnet-mirror-mint, devnet-register-mint, or devnet-mint-token. The §2 exposure, however, is live today against the public deployment.
4. Impact
Today: unauthenticated pollution of a registry two live routes consult, with attacker-influenced metadata (decimals, symbol) and no API-level repair. Latent: permanent mirror-creation poisoning for any squatted CA the moment a devnet_mints.by_mainnet_ca consumer is (re)introduced. Severity Low reflects the removed primary consumer; the unverified-write defect itself is unconditional.
5. Proof of concept
# 1) Unverified write — the "mint" need not exist on any cluster.
CA="<any mainnet CA not yet in devnet_mints>"
curl -s -X POST https://<playground-host>/api/devnet-register-mint \
-H 'Content-Type: application/json' \
-d "{\"mintAddress\":\"$CA\",\"name\":\"Squatted\",\"symbol\":\"SQAT\",\"decimals\":18}"# → {"status":"registered","mintAddress":"$CA"}# Registry row: mainnet_ca=$CA, devnet_mint=$CA, decimals=18 (unverified, unrepairable)# 2) Permanence — re-registration cannot repair:
curl -s -X POST https://<playground-host>/api/devnet-register-mint \
-H 'Content-Type: application/json' \
-d "{\"mintAddress\":\"$CA\",\"name\":\"Legit\",\"symbol\":\"REAL\",\"decimals\":6}"# → {"status":"registered"} but the row is UNCHANGED (onConflict + ignoreDuplicates).# 3) Live consumer effect — devnet-airdrop resolves through devnet_mints and trusts# the stored decimals (route.ts:491-503): the squatted row's decimals=18 feeds# 10**decimals in any airdrop that resolves through it; for the self-referencing# row the native-mint fallback engages — the registry's row shape directly# steers a live route's behavior.# 4) LATENT chain — fire mirror-mint directly (what any future consumer would hit):
curl -s -X POST https://<playground-host>/api/devnet-mirror-mint \
-H 'Content-Type: application/json' \
-d "{\"mainnetCA\":\"$CA\",\"walletAddress\":\"<any wallet>\"}"# → {"status":"existing","devnetMint":"$CA","name":"Squatted",...}# Asserts a devnet mint exists — but $CA is a MAINNET address. No devnet mint# is ever created for this CA again (early return + ignoreDuplicates everywhere).
6. Recommended fix
Gate registration on a property only genuine server-created mirror mints possess — their mint authority — copying the in-repo pattern from devnet-pre-fund/route.ts:402-429:
// after the format check (line 86):constmintInfo=awaitconnection.getMint(newPublicKey(mintAddress));// throws if absent on devnetif(!mintInfo.mintAuthority?.equals(serverMintAuthorityPk)){// getDevnetMintSigner().publicKey()returnNextResponse.json({error: "Not a Percolator mirror mint"},{status: 400});}
Given that no frontend calls this route (or devnet-mint-token/devnet-mirror-mint) at HEAD, removing the three legacy routes — or gating them behind checkAdminSecret — equally retires the latent chain. If they stay: join the shared durable fund limiter (checkFundRateLimit) in place of the per-instance Map (#2477 class), and replace ignoreDuplicates: true with a plain upsert so verified re-registrations can repair rows.
7. References
app/app/api/devnet-pre-fund/route.ts:402-429 — in-repo on-chain mint-authority verification to copy
app/app/api/devnet-airdrop/route.ts:491-503 — live consumer trusting devnet_mints metadata (decimals)
app/hooks/useCreateMarket.ts:3342-3346, app/components/create/CreateMarketWizard.tsx:330-342 — the removed mirror model (why the chain is latent)
POST /api/devnet-register-mint(the writer);POST /api/devnet-mirror-mint(the poisoned consumer)app/app/api/devnet-register-mint/route.tslines 82-86 (format-only check), 112-121 (upsert);app/app/api/devnet-mirror-mint/route.tslines 248-260 (early return), 384-396 (ignoreDuplicatesupsert)7ce465b— HEAD ofplaygroundas of 2026-08-16. First verified at8d3e280; re-verified after GH#2519 (confirmation checks only — registry logic untouched)devnet_mintsrows gate user-facing flows), #2477 (this route's per-instance-only throttle)TL;DR
POST /api/devnet-register-mintwrites rows intodevnet_mints— the registry that/api/devnet-airdropand/api/devnet-pre-fundconsult — after checking nothing but the base58 shape of the submitted address. Anyone can insert rows naming any mainnet CA with attacker-chosensymbolanddecimals, at 20 rows/hour per serverless instance, and those rows can never be corrected through the API (ignoreDuplicates: true). Today this pollutes a registry that two live routes trust for authorization and amount math; if/api/devnet-mirror-mintis ever re-wired to a frontend flow, the same rows permanently break mirror creation for every squatted CA.1. What the route does
The route exists to record a devnet mint in the
devnet_mintsmapping table (mainnet_ca→devnet_mint, plusname,symbol,decimals). Its entire validation posture is:Never verified: that the address exists on devnet, that it is an SPL mint, or that its mint authority is the server keypair — a check this codebase already ships in
app/app/api/devnet-pre-fund/route.ts:402-429(getMint()+ authority comparison).2. What is exploitable today (verified at HEAD)
ignoreDuplicates: true(line 120) means a squattedmainnet_carow can never be corrected by re-registration; cleanup requires direct DB access.decimalsfeeds consumer math —/api/devnet-airdropreadsdecimalsfromdevnet_mintsrows (route.ts:491-503) and scales grant amounts with10 ** decimals; the stored value is accepted verbatim (any integer 0-18)./api/devnet-pre-fundtreats adevnet_mintsrow as "permitted" (route.ts:190-215); for a squatted CA the row'sdevnet_mintequals the mainnet address, so the DB check passes and only the downstream on-chaingetMintauthority check stops the mint. The allowlist is being fed unverified data.3. The latent poisoning chain (why severity is Low, not Medium)
If
/api/devnet-mirror-mintis ever called with a squatted CA — by a future wizard re-wiring, old runbook docs, or any new consumer — it consults the registry before creating a mirror and returns early on any hit:The caller is told a devnet mint exists; none does. Every later on-chain step fails far from the cause, and both repair paths refuse (
ignoreDuplicateshere at line 120 and in mirror-mint's own upsert at line 392) — the mapping is broken permanently until manual DB cleanup.Why latent: the current wizard no longer calls
devnet-mirror-mintat all — the mirror-collateral model was removed (CreateMarketWizard.tsx:330-342: "devnet collateral is ALWAYS Sim-USDC … skip it for every devnet market"), and the post-launch airdrop goes through/api/devnet-airdrop(useCreateMarket.ts:3342-3346). A repo-wide search finds no runtime frontend caller fordevnet-mirror-mint,devnet-register-mint, ordevnet-mint-token. The §2 exposure, however, is live today against the public deployment.4. Impact
Today: unauthenticated pollution of a registry two live routes consult, with attacker-influenced metadata (
decimals,symbol) and no API-level repair. Latent: permanent mirror-creation poisoning for any squatted CA the moment adevnet_mints.by_mainnet_caconsumer is (re)introduced. Severity Low reflects the removed primary consumer; the unverified-write defect itself is unconditional.5. Proof of concept
6. Recommended fix
Gate registration on a property only genuine server-created mirror mints possess — their mint authority — copying the in-repo pattern from
devnet-pre-fund/route.ts:402-429:Given that no frontend calls this route (or
devnet-mint-token/devnet-mirror-mint) at HEAD, removing the three legacy routes — or gating them behindcheckAdminSecret— equally retires the latent chain. If they stay: join the shared durable fund limiter (checkFundRateLimit) in place of the per-instance Map (#2477 class), and replaceignoreDuplicates: truewith a plain upsert so verified re-registrations can repair rows.7. References
app/app/api/devnet-pre-fund/route.ts:402-429— in-repo on-chain mint-authority verification to copyapp/app/api/devnet-airdrop/route.ts:491-503— live consumer trustingdevnet_mintsmetadata (decimals)app/hooks/useCreateMarket.ts:3342-3346,app/components/create/CreateMarketWizard.tsx:330-342— the removed mirror model (why the chain is latent)devnet_mintsrow state gates user flows