DEV Community

TxDesk
TxDesk

Posted on

Three Sui Exploits in One Week. So I Built 5 Security Tools to Catch Them.

In nine days, three Sui DeFi protocols got hit. Volo lost $3.5M on April 21. Scallop lost $142K on April 26. Aftermath Finance lost $1.14M USDC on April 29.

Three different protocols, three different attack patterns, one shared root cause: nobody had a way to check the structural risk before signing.

The three patterns

Scallop: Sui packages don't disappear when you upgrade them. They get superseded — but the old version stays callable on chain forever. Scallop's V2 staking-rewards package from November 2023 sat dormant for 17 months until someone found an uninitialized last_index counter and claimed rewards from a synthetic position that "existed since the spool launched." The frontend pointed at the new version. The on-chain remnants didn't care.

Volo: Not a smart-contract bug. The contracts were audited. The single keypair holding upgrade authority over three vaults got compromised. $3.5M gone in one signing session. The audit didn't matter because the audit assumed the key was safe.

Aftermath Finance: A public entry function called add_integrator_config had no authorization check. The attacker set max_taker_fee to 0. A signedness bug then interpreted that as negative. They got paid to trade. Eleven transactions, 36 minutes, $1.14M.

Three patterns: deprecated code still callable, single-key admin, missing auth on a public entry. None of them are detectable by reading dApp UIs. All three are detectable from RPC data.

The five tools

I built five Sui-specific security tools for TxDesk, the AI support layer for crypto products I've been working on. Each tool is a single TypeScript service, fully tested, plugged into the agent's tool registry.

assess_sui_package_risk. Detects deprecated package versions by walking the UpgradeCap chain — the original Scallop pattern. Classifies cap ownership (single-key vs Shared multisig vs Immutable) — the original Volo precondition. Counts public entry functions that don't take a Cap parameter — a heuristic for the AftermathFi pattern. The interesting bit: my original plan called for three discovery paths to find the UpgradeCap. Smoke testing against mainnet revealed Sui's 0x2::package module emits no Move events at all, so the event-based path was structurally impossible. Deleted it. The remaining publish-tx scan does all the work, faster.

diagnose_failed_sui_transaction. Classifies eight failure categories — INSUFFICIENT_GAS, MOVE_ABORT_SLIPPAGE, MOVE_ABORT_AUTH, MOVE_ABORT_GENERIC, OBJECT_VERSION_CONFLICT, SHARED_OBJECT_CONGESTION, INVALID_GAS_OBJECT, TYPE_ARGUMENT_ERROR — with plain-English suggestions per category. The interesting bit: I tightened the slippage heuristic during planning. The original idea was to guess slippage from module name alone (any abort in a pool module = probably slippage). That's wrong. Many functions in pool modules aren't swaps. Now slippage requires BOTH the module name to match (pool|swap|amm|dex|router) AND the function name to match (swap|trade|exchange|exact_(in|out)|exec). If the function name isn't resolvable from the abort error string, classification falls back to MOVE_ABORT_GENERIC. False negatives over false positives.

inspect_sui_object. Single-RPC tool that returns object type, ownership kind (one of AddressOwner / ObjectOwner / Shared / Immutable), version, and decoded content. For Coin<T> objects, a parallel suix_getCoinMetadata(T) call decodes the balance with proper decimals. The interesting bit: when the metadata fetch fails, we surface the raw balance string and decimals: null rather than defaulting to the SUI decimals. Showing "1,500,000,000 (raw, decimals unavailable)" is honest. Showing "1.5 SUI" when we don't know the actual decimals would be a guess.

check_sui_coin_metadata. Answers "is this token legit, and who can mint it?" Validates the coin type structure (also accepts the wrapped form 0x2::coin::Coin<...>), fetches metadata and total supply, locates the TreasuryCap<T> and inspects its current owner. The interesting bit: I introduced an RpcOutcome<T> discriminated union here — { ok: true; value: T | null } | { ok: false }. The reason is subtle. For metadata, a null result from suix_getCoinMetadata means "definitively no metadata published" (a scam signal). A network error means "we don't know yet." The original safeRpcCall helper flattened both to plain null, which would have falsely flagged real coins as scams during transient RPC outages. The discriminated union forces the call site to distinguish.

check_sui_account_risk. SUI balance, owned-object inventory, UpgradeCap inventory, recent transaction count. Flags addresses holding upgrade authority over five or more packages as CRITICAL — the Volo blast-radius pattern. The interesting bit: a 30-second total operation timeout wraps the entire pipeline. Whales with thousands of owned objects could otherwise drag the agent. If the deadline fires mid-pagination, the report returns with coverageComplete: false, which forces riskLevel: 'UNKNOWN'. We never fabricate a "looks fine" answer from a partial scan.

The mainnet smoke test

I picked Cetus CLMM as the target. It's a well-known Sui DEX, handles real daily volume, and the team is reputable. The package ID came from the Cetus contracts Move.toml on GitHub: 0x1eabed72c53feb3805120a081dc15963c204dc8d091542592abaf7a35689b2fb.

The agent classified the intent as security_concern, routed correctly to assess_sui_package_risk (not assess_contract_risk — the EVM version), and returned CRITICAL in 1.7 seconds (post-cleanup). Two findings:

  1. isLatestVersion: false. The package was superseded by 0x25ebb9a7…dfee5e3. Calling the old version is the Scallop pattern, live in production on a real protocol.
  2. upgradeCapOwnerKind: 'AddressOwner'. A single keypair (0xdbfd…4a47) controls upgrades. The Volo precondition.

That's not a hand-picked test fixture. That's the product working on a real Sui DEX on day one.

The never-lie principle

The default engineering reflex when an API call fails is to return false. It compiles. It type-checks. It doesn't crash. And it's a lie.

"API failed" and "the answer is no" are different statements. Defaulting to false collapses them and propagates a wrong answer with full confidence.

Every Sui tool I built uses nullable booleans for every signal that could fail: isPackage: boolean | null, isLatestVersion: boolean | null, treasuryCapStatus: SuiTreasuryCapStatus | null. Each report includes a dataAvailable: 'full' | 'partial' | 'unavailable' flag. Only 'full' reports are cached. Partial reports are returned to the user but never written to Redis, so the next call retries.

Concrete example. If we can't find the UpgradeCap for a package — the publish tx got pruned, the RPC timed out, whatever — we don't say there's no cap. We say upgradeCapId: null, upgradeCapOwnerKind: null. Those are different statements. The first would imply an immutable package. The second admits we don't know.

The cost: users sometimes see "we couldn't determine X." The benefit: when we DO say something, it's worth trusting.

What mainnet smoke testing taught me

I wrote all five tools, wrote 87 tests, all green. Then I ran four curl commands against the actual Sui mainnet RPC. Three findings:

  1. The SuiVision verification URL I'd put into the package-risk service (api.suivision.xyz/v1/packages/...) didn't resolve. DNS error. The endpoint I'd assumed existed never did.
  2. The Move event filter for UpgradeCap discovery (MoveEventType: '0x2::package::PublishEvent') returned empty 200 responses. Broadening to the entire 0x2::package module returned zero events from any source. Sui packages don't emit Move events for publish — at all.
  3. The CurrencyCreated event filter for TreasuryCap discovery DID return events, but the event type is generic (CurrencyCreated<T>) so a non-parameterized filter never matches, and the event's parsedJson only contains {decimals} — not the cap ID I'd assumed.

All three findings led to deletion, not workarounds. SuiVision call: deleted entirely. Path A in package-risk: deleted (Sui literally cannot provide what I was asking for). Path A in coin-metadata: replaced with a publish-tx scan that piggybacks on a sui_getObject call already happening, costing one additional RPC instead of three.

Tool execution dropped 46%. Code got smaller. The result is more honest. The lesson: never write a code path that depends on an API behavior you haven't verified — and when smoke testing reveals that path is dead, delete it. Don't leave it as a "best-effort fallback" that's actually a no-op.

Numbers

  • 5 new services: 2,893 lines
  • 5 test files: 2,080 lines
  • 87 new tests, 1,097 across the codebase
  • 37 tools total in TxDesk now (up from 32)
  • One evening session, planning through commit 54b2b40
  • Smoke test caught real CRITICAL issues on Cetus CLMM on day one

Closing

If you're building on Sui or using Sui DeFi protocols, these tools are live at txdesk.io. And if you're a protocol team dealing with fifty identical "am I affected?" messages after every exploit — that's the problem TxDesk solves.

Top comments (0)