Bounty: Break It Before Users Do — MultiHopper Agentic Flow Bugs & Fixes (Superteam Earn)
What I built / used: A small Python harness (requests, base58, solders, python-dotenv) driving the live devnet API through the full agentic transfer lifecycle (quote → prepare → confirm-broadcast), webhook registration and delivery, real on-chain Solana transaction signing/broadcasting, and authorization boundaries. Environment: Solana devnet only, mh_test_-prefixed API key. No mainnet endpoints or live user funds were touched at any point.
Repository: private (available on request — see contact below)
Contact: @takumixbt on X / @takumixbt on Telegram
| Severity | Count |
|---|---|
| Critical | 1 |
| High | 4 |
| Medium | 15 |
| Low | 3 |
| Documentation Blocker | 3 |
One additional low-severity observation, F7, is noted separately at the end and isn't counted in this table since it's most likely an intended limitation of the devnet test environment rather than a real bug.
The two I'd flag as most interesting: premium-tier transfers get permanently stuck once quoted and can never be prepared (F3), and the fee tier itself is decided by a client-supplied, unvalidated decimals value that silently overcharges native-SOL transfers by ~10x and lets a malicious integrator evade protocol fees on purpose (F20), which also turns out to be the root cause of F3's "every transfer landed in premium tier" observation. Following F20's fix to its logical conclusion, forcing the correct decimals value gets a transfer past prepare, but that only exposes a second, stranger bug: the transfer then silently hangs forever, indistinguishable from a healthy one (F22). It turns out completing it actually requires the client to sign and broadcast five raw Solana transactions that are never documented or surfaced anywhere in the API, which I confirmed the hard way by driving one transfer through that undocumented flow to an actual status=completed, the only one in this whole engagement (F25). Actually doing that broadcast turns out to have its own sharp edges: one of the five transactions comes back partially pre-signed by the server, and a naive client that rebuilds its signature array clobbers that signature and fails on-chain (F29); broadcasting them out of the documented order can permanently brick a transfer within that prepared set (F30); and an agent that crashes mid-broadcast can resume, but only if it knows an undocumented trick, and the API's own "is the keeper funded" resume hint is unreliable (F31). /transfers/estimate separately understates both what the recipient will receive (F17) and what the sender needs to fund upfront (F19).
| ID | Severity | Title |
|---|---|---|
| F1 | Critical | Webhook SSRF (internal and private-network URLs accepted) |
| F3 | High | Transfers get stuck permanently after quoting |
| F17 | High | /transfers/estimate understates the recipient's payout |
| F20 | High | Fee tier decided by client-supplied tokenDecimals |
| F22 | High | Standard tier gets past F3, straight into a silent dead end |
| F19 | Medium | sol.breakdown omits a real cost from its own total |
| F23 | Medium | prepare is non-idempotent and un-phase-gated |
| F28 | Medium | Repeated prepare mints genuinely independent on-chain accounts |
| F29 | Medium | preparedTxs includes a server-partial-signed transaction |
| F30 | Medium | Broadcast ordering not gated; out-of-order broadcast bricks a transfer |
| F31 | Medium | Resume after interrupted broadcast works but is fragile |
| F2 | Medium | No way to cancel a transfer |
| F14 | Medium | Empty events: [] on a webhook silently means "everything" |
| F24 | Medium | Webhook delivery retries once then drops the event forever |
| F9 | Medium | A raw DB error leaked schema details on a 500 |
| F26 | Medium | rescue/confirm reproducibly returns a raw 504 |
| F11 | Medium | amountRaw and amountTokens aren't cross-checked |
| F12 | Medium | Program addresses accepted as transfer recipients |
| F13 | Medium | GET /usage always 500s |
| F21 | Medium | Rate-limit state completely invisible, even on the 429 itself |
| F4 | Low | Raw Fastify errors leak instead of the MH error schema |
| F18 | Low | Idempotency-Key collisions cross endpoints |
| F27 | Low | progress.hopsTotal isn't stable or monotonic |
| F25 | Documentation Blocker | Standard-tier transfers complete only via an undocumented broadcast flow |
| F5 | Documentation Blocker | DELETE silently requires an Idempotency-Key |
| F6 | Documentation Blocker | Undocumented 5-webhook-per-key cap |
| F7 | Note (unscored) | Devnet SPL transfers don't work at all |
| URL | Result |
|---|---|
http://localhost:8080/webhook |
accepted |
http://127.0.0.1:8080/webhook |
accepted |
http://10.0.0.1:8080/webhook |
accepted |
https://[email protected]/ (credentials embedded) |
accepted |
https://webhook.site/ |
accepted |
Impact: Anyone who can register a webhook (their own account, or a compromised upstream integration) can point MultiHopper's server-side requests at localhost, an internal RFC 1918 range, or a URL with embedded credentials. That's internal service probing, private network reconnaissance, or a credential-injection attack, all launched from MultiHopper's own infrastructure rather than the attacker's.
Proposed fix: Validate the URL at registration (reject non-HTTPS, embedded credentials, and any hostname that resolves to a private or loopback address) and re-validate on updates, since DNS can be repointed after the fact.