RippleBids Launchpad, DEX & Profile Audit
A full read of the XRPL launchpad, the Solana bonding-curve program, the DEX market-data/trading surface, and profile integration — architecture, security, and correctness, with a prioritized fix plan.
Executive Summary
Two items below are live risk, not backlog — they're called out first because they warrant action before the rest of this document is read in full.
1. A live fund-drain path in the Solana program (mainnet)
rippleverse_sc is deployed to mainnet (PROGRAM_ID=Fqrt56tG9aLgj42bebCDcM8MmesVj9H3NJiweaaKyaXh). Its sell instruction never verifies that the token-vault account it's given is the real bonding-curve vault — it trusts whatever account the caller passes. Anyone can buy a token once, then repeatedly call sell while substituting their own token account for the vault: the program still pays real SOL out of the real vault each time, while the attacker's tokens never leave their wallet. No secrets or server access needed — just public transactions against the deployed program. See Solana § Finding 1.
Do now: pause trading via toggle_pause, then check recent sell transactions and each curve's real_sol_reserves against actual vault balances for signs of prior exploitation.
2. Two live secrets committed to the backend repo
trustbridge.py hardcodes the production MySQL password. functions/downpour.py hardcodes an XRPL wallet seed — a private key, usable by anyone who reads the file, no exploit required. Both are in git history permanently, not just the working tree. See XRPL § Findings 1–2.
Do now: check that wallet's balance and move any funds out; rotate the DB password on Aiven; treat the wallet as burned.
| Section | Critical | High | Medium | Low | Info |
|---|---|---|---|---|---|
| Solana launchpad program | 1 | 1 | 3 | 2 | 1 |
| XRPL launchpad & escrow | 4 | 3 | 3 | 2 | 1 |
| DEX data & trading flow | 0 | 1 | 2 | 2 | 3 |
| Profile integration | 0 | 0 | 2 | 2 | 2 |
| Total | 5 | 5 | 10 | 8 | 7 |
Solana Launchpad Program
Scope: programs/ripple_trades/src/* (all instructions, state, Raydium/Metaplex CPI helpers), tests/, and the frontend's on-chain integration. One scope note up front: despite the README describing an Anchor program, Cargo.toml depends only on solana-program + spl-token — this is hand-rolled native Solana, with no Anchor account-validation guard rails. That absence is directly relevant to the top finding.
The bonding-curve token vault address is never validated — full vault drain
The program checks the SOL-holding PDA (curve_ai) but never verifies that the paired token account (curve_tok_ai) is the real vault — no comparison against get_associated_token_address(curve_ai, mint), no owner check. An attacker can substitute their own token account as curve_tok_ai in sell(): the program still transfers real lamports out of the real curve_ai vault and updates real_sol_reserves as if a legitimate sale occurred, while the attacker's tokens stay in their own control and can be replayed. The same missing check exists in graduate.rs (line 55, used at 136–153) for the LP-reserve token account.
Fix
Store the expected vault address on-chain at launch_token time (or re-derive it each call) and reject any curve_tok_ai/wsol_tok_ai that doesn't match — the same pattern as Anchor's has_one, applied by hand.
Raydium CPI accounts trusted from the caller, never derived or checked
Only raydium_prog_ai and pool_fee_ai are checked against hardcoded constants. amm_config_ai, ray_auth_ai, pool_state_ai, both mint accounts, lp_mint_ai, both vaults, and obs_state_ai are never derived/verified against the PDA helper functions already written in raydium.rs (lines 33–60) but never called. Since graduate() is permissionless, an attacker-as-payer could pick an unfavorable amm_config fee tier, or the locally-computed sol_is_token_0 (line 134) could silently mismatch the mint order actually passed to Raydium.
Fix
Derive and assert every Raydium PDA locally before the CPI; pin amm_config to a value stored in PlatformConfig rather than accepting caller choice.
toggle_pause doesn't gate every user-facing instruction
is_paused is checked in launch_token, buy, sell, graduate, and escrow::process_init — but not in process_fund, process_release, process_refund, or anywhere in claim_creator_rewards.rs. This contradicts the README's claim that pause blocks all user-facing instructions. Pausing today to contain the vault-drain bug above would not stop escrow or reward-claim fund movement.
Fix
Load and check cfg.is_paused in all four instructions.
The test suite targets an interface the program no longer has
Tests assume a 1% / 3-way fee split via Anchor's program.methods/IDL and omit the creator_rewards/referrer accounts entirely. The shipped code (constants.rs lines 12–32) uses a 1.5% fee across 4 recipients plus referral, and buy/sell require 15/13 accounts including those two. The suite cannot run against the current program — meaning zero automated coverage exists for the curve math, fee split, creator rewards, referrals, escrow, tiers, or graduation, including the vulnerability above.
Fix
Rewrite the suite against the actual instruction set before relying on it for anything, including before re-enabling trading after a fix.
No timelock or multisig on admin actions
update_config, toggle_pause, authority transfer, and withdraw_sol all execute instantly on a single EOA signature. A compromised authority key can redirect fee wallets, change fee splits, or pause/unpause with no delay for users to react. The README only recommends multisig for the treasury/liquidity destination wallets, not the authority key itself.
README materially diverges from the shipped code
README claims Anchor (not used), 1% / 3-way fee (actual: 1.5%, 4-way + referral), and overflow-checks = true in release (actual: false, an intentional size trade-off). It also references an events.rs for indexer events that doesn't exist. Not exploitable directly, but anyone configuring monitoring or fee expectations off this document will be wrong.
Escrow uses raw (non-checked) lamport arithmetic
Plain -=/+= instead of the checked_sub/checked_add pattern used everywhere else in the program. Combined with overflow-checks = false, a violated invariant would silently wrap rather than panic. Currently safe only because fee_bps is bounded by UserTier::fee_bps() — an implicit invariant, not an enforced one.
Curve math uses u128 intermediates with checked_* throughout, and truncation always rounds in the pool's favor on both buy and sell. The fee split absorbs rounding dust into treasury rather than letting any party gain from it. Slippage bounds are enforced atomically before any transfer. is_complete vs. is_graduated is a sound separation, and Solana's per-transaction atomicity means a failed Raydium CPI during graduate() reverts everything — no partial-migration state exists. Two-step authority transfer and withdraw_sol's scoping to the config PDA's own excess lamports are both correctly implemented.
Architecture assessmentThe bonding-curve → Raydium-CPMM-graduation model is still defensible in 2026 — cheap, well understood by traders, and the curve math here is genuinely correct. I wouldn't swap the AMM curve or the graduation DEX. The real risk is a different decision entirely: building this as a hand-rolled native program instead of an Anchor program. That trade gave up exactly the guard rails — typed account deserialization,
has_one/seeds =constraints, automatic signer/owner checks — that would very plausibly have caught both findings above during normal development, since Anchor forces every account relationship to be declared and checked explicitly.Before mainnet trading resumes: fix the account-validation findings above; rewrite the test suite against the current instruction set; get an independent second audit specifically because this is hand-rolled validation rather than an established framework, which is a higher-risk category on its own; and add a short timelock or multisig on
update_config/toggle_pause/authority transfer.
XRPL Launchpad & Escrow
Scope: functions/xrpl_launchpad.py, escrows.py, trades.py, downpour.py, storefront_orders.py, auth.py, trustbridge.py, extensions/extensions.py, and the frontend's Xaman/signing integration.
Live production database password committed in plaintext
A standalone schema-export script hardcodes the Aiven MySQL host, user, and password for the production database. Anyone with repo access has full read/write to production — xrpl_launches, escrows, wallets, everything.
Fix
Rotate the credential immediately; load it from env instead; audit git history and Aiven access logs for prior use.
Custodial XRPL wallet seed hardcoded in source
XUMM_API_KEY, XUMM_API_SECRET, and DOWNPOUR_WALLET_SEED are literal strings, not env reads. This seed is the private key for the wallet meant to pay out XRPB reward batches. Anyone with source access can drain it directly with xrpl-py — no server compromise needed.
Fix
Treat the wallet as burned. Move funds, rotate to a design that doesn't hold a bare seed server-side (see architecture note below).
Zero authentication on fund-moving and escrow endpoints
None of these routes carry a JWT check, admin check, or the HMAC verify_signature decorator that exists elsewhere in the codebase — combined with CORS(app, origins="*") in extensions.py:22, they're open to the public internet. The one real signing scheme (verify_signature in auth.py, matched by app/lib/signRequest.js on the frontend) is only ever applied to marketplace/listings and auth/register|login.
Fix
Extend verify_signature/JWT admin checks to every route in downpour.py and escrows.py before any of this handles real funds again.
Escrow "funded" status is a client-asserted boolean, never verified on-chain
create_escrow() takes paymentVerified directly from the request body and marks the order funded if it's truthy — the transactionHash is stored but never checked against XRPL or Solana. Any client can call /create with {"paymentVerified": true} and get an order marked escrow_funded with no payment made. storefront_orders.py has the identical pattern, gated only on the presence of a client-supplied escrow_id.
Fix
Verify the transaction hash against the chain before flipping status — or move to native XRPL Escrow objects, where "funded" is a ledger fact, not a claim.
"Escrow" has no release or refund logic at all, on-chain or off
No EscrowFinish/EscrowCancel equivalent exists anywhere in the backend — only create, get_all, get_details. update_order lets the listing owner PATCH status to any arbitrary string, but nothing consumes that to actually move funds out of ESCROW_ADDRESS. The stored conditions (delivery required, auto-release days) are written once and never read again. Today, "escrow" is a database label with no code path that ever moves funds from it.
Payout execution is broken (undefined variable) — but confirms the intended custodial design
execute_batch_payouts references wallet.classic_address, but wallet is never assigned anywhere in the file — every call throws NameError, silently swallowed by a broad except. This currently self-mitigates the fund-moving risk from the missing-auth finding above, but once someone "fixes" the obvious bug by adding Wallet.from_seed(DOWNPOUR_WALLET_SEED), it combines with that finding into a live drain vector.
The one real signing scheme has a public default secret, on both sides
SIGNING_SECRET = os.getenv("SIGNING_SECRET", "devtomiwaisperfect") — and the frontend has the identical fallback literal. If either deployment ever runs without the env var set (plausible, since .env.example ships it blank), the HMAC scheme becomes forgeable by anyone who's read this source.
Replay protection is weak and non-durable
used_nonces is an in-process Python set, remembered for only 60 seconds, and the timestamp-freshness check is commented out. Under multiple workers, a nonce accepted by one worker is unknown to another — the same signed request can be replayed once per worker, or after the window elapses regardless.
GET /api/escrows returns every escrow to anyone
No auth, pagination, or filtering — buyer/seller wallet addresses, amounts, and deal terms are disclosed to any unauthenticated caller.
Column lists built with f-strings in update helpers
Values are parameterized, and column names currently come from a fixed internal whitelist, so this isn't exploitable today — but the pattern is fragile. A future contributor adding a client-controlled key without noticing the string-formatting would introduce real SQL injection.
Dead custodial-wallet scaffolding left in the launchpad file
DISTRIBUTION_ADDRESS/DISTRIBUTION_SEED and unused Wallet/submit_and_wait imports are leftovers from an earlier custodial design. The actual launch flow is non-custodial and sound (see architecture note) — this dead code just risks confusing the next engineer, or being "helpfully" wired back up.
Status transitions have no row lock
Status is read, checked in Python, then written in a separate UPDATE with no SELECT ... FOR UPDATE or conditional write guarding against two concurrent requests for the same launch. Low likelihood, easy to harden.
The token-launch flow itself (xrpl_launchpad.py) is genuinely non-custodial: the creator's Xaman wallet signs AccountSet/AMMCreate, and the backend only ever submits pre-signed blobs, cross-validating the on-chain result against its own DB record before flipping status. That's the right pattern for a launchpad, and it's the one thing here that should be the template for the rest.
Architecture recommendationWhat this codebase actually does today is split down the middle. The launch path is non-custodial and correctly designed. Everything adjacent to it — reward payouts and marketplace escrow — is built around a raw seed sitting in the Flask process, with none of the controls that would make that safe: no auth on the triggering endpoints, no on-chain verification before marking funds released, and currently no working signer at all.
I would not "fix" the payout path by patching the
NameErrorand calling it done — its being broken is the opportunity to redesign it before it moves real money. Concretely: keep the launchpad's non-custodial pattern as-is and delete the dead scaffolding so nobody restores custodial signing there by mistake. Convert reward payouts to require an operator signature per batch (Xaman/multisig) rather than an unattended seed, or move the seed into a KMS/HSM-backed signer gated behind real admin auth and a second approval step. Replace the DB-only "escrow" concept with native XRPLEscrowobjects for the on-chain leg — conditional release enforced by consensus rules, not a backend that has to be trusted to eventually call a release function that doesn't exist yet — keeping MySQL only for order metadata that doesn't belong on-chain. The XRPL side's use of native AMMCreate instead of a custom bonding curve is the right idiomatic choice for that chain; I wouldn't try to force the Solana bonding-curve model onto it.
DEX Data & Trading Flow
Scope: app/trade/**, app/api/trades/, app/api/solana/bonding-curves, and the backend routes that serve them. Ordered by the five capabilities requested, then the trading flow itself.
useTokenHolders.js calls /api/ripplebids/trades/holders/<mint>, but the Flask backend has no such route — only /xrpl/holders/<issuer> exists. Every Solana holders request 404s and silently renders "No holder data yet" for every Solana token, always. A working alternative already exists and is unused: app/api/trades/holders/[mint]/route.js falls back to real on-chain data via getTokenLargestAccounts, but nothing calls it. XRPL holder ranking, meanwhile, works but is a net-flow approximation (buy/sell deltas) rather than actual trust-line balances, so ranks can be wrong.
Fix
Point the Solana hook at the working route (or fix the proxy path); rebuild XRPL "top holders" from real account_lines balances.
No pie/donut/treemap component exists anywhere under app/trade. The percentages needed are already computed by useTokenHolders (h.pct) and rendered only as a bar list — this is close to a pure front-end task once the holders bug above is fixed.
XRPL order book is fetched live and entirely client-side from the ledger (book_offers + AMM reserves), correctly merged and sorted. Solana tokens use a bonding curve, so no order book is shown for them — a correct design choice, not a gap.
Chart data itself works when the underlying tables are populated — real OHLCV built from indexed trades. But token_trades/xrpl_trades are only ever written via a passive POST /trades/indexer/events endpoint; no indexer process pushing to it was found in either repo, so if one isn't deployed, charts/activity silently stay empty. Separately, useChartLive.js/useTokenTrades.js both open a socket.io-client connection expecting a realtime feed — grepping the entire backend for socketio/emit( returns zero matches. flask_socketio is an installed dependency that's never imported or instantiated. The UI degrades gracefully (labels data "History" instead of falsely claiming "Live"), but the client retries this dead socket forever.
Fix
Either stand up a real Socket.IO emitter alongside the indexer write path, or remove the socket.io client code to stop the infinite reconnect loop against a server that will never accept it.
Both chains compute price/market-cap/supply directly from on-chain state (Solana: batched bonding-curve account reads; XRPL: on-chain values preferred over off-chain metadata on conflict), with sensible fallbacks rather than crashes on missing data.
Every trade re-fetches the bonding curve fresh from chain, computes a slippage-bounded minimum, simulates before signing, and — critically — the on-chain program independently re-checks that same bound (buy.rs:102, sell.rs:97) and rejects the transaction if violated. Prices aren't merely trusted client-side; they're enforced on-chain. Gap: slippage is hardcoded at 1% with no UI control to adjust it.
Trading flow — XRPL: safe by ledger design (XRPL's own SendMax/Amount semantics bound overspend), but uses a fixed 2% buffer rather than user-adjustable slippage, against a price that can be up to 5 seconds stale.
A soft spot worth knowing about: the proxy at app/api/ripplebids/[...path]/route.js converts any upstream network failure into an HTTP 200 with a synthetic empty payload. Good for UX continuity, but it means a genuine backend outage looks identical to "no data yet" for every endpoint it covers, including holders and token lookups.
Ideas for improving charts & market dataWire the distribution pie chart off the holder percentages already computed — a near-complete front-end task once the Solana holders route is fixed. Decide, one way or the other, on real-time: either emit real Socket.IO events from whatever writes
token_trades, or delete the client-side socket code so it stops trying forever. And surface data-freshness caveats in the UI — label USD conversions as estimated when the price API falls back to seed data, and show an "as of Xs ago" tag on the XRPL orderbook/price given the 5-second poll window, so displayed price and execution price don't surprise anyone.
User Profile Integration
Scope: app/(app)/profile, app/trade/profile, app/trade/portfolio, and the backend's user/storefront routes. Confirmed architecture: /api/ripplebids/* proxies to this Flask backend, while /api/uploader/* proxies to a separate external service (not in this repo) that owns bio/avatar/banner/videos — profile pages are a stitch of at least two backends today.
Two disconnected follower graphs for the same concept
The RippleVerse profile's Follow button runs on user_follows. A second, full follow/unfollow/stats route set exists in storefront_followers.py and is never called by anything in this frontend — two social graphs tracking "following a user" with no relationship to each other.
The entire storefront backend has no frontend consumer in this repo
No app/storefront directory exists at all. Two incompatible storefront schemas (storefront.py's seller-stats/orders/escrow model, and storefronts.py's richer themes/skills/social-links model) both sit unused by rippleverse_website. Worth confirming whether this serves a different, legacy frontend before building more "profile" features on top of a third system — and note that being unused by this frontend doesn't mean unreachable: the blueprints are still registered in index.py and, per the XRPL audit, storefront_orders.py shares the same unverified-payment pattern found in escrows.py. Whether the rest of this surface has the same auth gaps as escrows.py/downpour.py wasn't in scope for this pass and is worth checking before assuming it's dormant risk-free.
Dead link
"View All" under Tokens Launched links to /trades, which doesn't exist as a route (the app only has app/trade/*, singular) — a 404.
UI reads profile fields that can never be populated
location, joined_at, is_verified, twitter, youtube, instagram are rendered from profile, but no such columns exist anywhere on the backend's users table (only bio/profile_pic_url). Pure dead UI, not just unwired.
Server-side watchlist is fully built and completely unused
GET/POST /trades/watchlist and its DELETE counterpart are implemented against a real trades_watchlist table, but useWatchlist.js stores everything in localStorage only. Watchlists don't sync across devices and can't be shown on someone else's profile even if the owner wanted that.
Personal trade history and P&L are one query away, but not built
Trade rows already key a trader wallet address per row, and the existing holder-aggregation logic shows how to roll that up into a position. Nothing today queries "trades by the connected wallet" to show a profile owner's own buy/sell history, volume, or basic realized/unrealized P&L — the columns needed already exist.
SummaryA user's identity today is really three or four unrelated systems: an external uploader service for bio/avatar, this backend's near-duplicate
userstable, the trading/launchpad domain exposed piecemeal through/trades/*, and an entirely separate, entirely unused storefront/seller identity. The highest-value, lowest-effort wins are wiring the existing watchlist and per-wallet trade-history data into the profile pages that already have the UI shape for them, fixing the dead link and dead fields, and deciding — before investing further — which of the two storefront schemas (if either) is canonical.
Recommended Action Plan
In sequence — each step assumes the ones before it are done.
Today
Check the XRPL wallet tied to
DOWNPOUR_WALLET_SEEDMove any funds out immediately; the seed is public to anyone who's read the source.
Pause the Solana program
Call
toggle_pausevia the authority key to stop further exploitation of the vault-drain path. Remember it doesn't cover escrow/creator-reward instructions.Look for signs of prior exploitation
Check recent
selltransactions and each curve'sreal_sol_reservesagainst actual vault balances; check Aiven DB access logs for unfamiliar connections.
This week
Rotate both secrets
New Aiven MySQL password; new XRPL wallet, ideally behind a regular key or multisig rather than a bare seed again.
Patch the Solana account-validation bugs
Vault-address check in
buy/sell/graduate; derive and assert every Raydium CPI account. Add tests that specifically try account substitution before unpausing.Add authentication to every fund-moving XRPL route
Extend
verify_signature/JWT checks todownpour.pyandescrows.py; move nonce tracking to something durable and reinstate the timestamp-freshness check; remove the hardcoded default signing secret on both frontend and backend.
This month
Redesign escrow and payouts before they touch real funds again
Native XRPL Escrow objects for the on-chain leg; operator co-signing or a proper KMS-backed signer for reward batches.
Fix the DEX data gaps
Solana holder endpoint mismatch, the distribution pie chart, and a decision on the dead real-time socket layer.
Wire existing backend data into profiles
Server-side watchlist, personal trade history/P&L, the dead link and dead UI fields.
Resolve the storefront/profile duplication
Pick a canonical system (or retire both storefront schemas) and confirm the rest of that blueprint doesn't share the auth gaps found in
escrows.py.
Ongoing
Independent second audit of the Solana program
Warranted specifically because it's hand-rolled native code rather than an established framework — a structurally higher-risk category.
Automated secret scanning in CI
Would have caught both hardcoded credentials before they were ever committed.