89188 w a medium official utxo pager orders only by block height skips a same height sibling and claim never sees that note
Submitted on Aug 17th 2026 at 17:59:27 UTC by @Omisanin0 for Audit Comp | Quantus
Report ID: #89188
Report Type: Websites & Apps
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/quantus_sdk
Impacts:
Temporary freezing of funds for at least 24 hour
Description
Brief
The official Encrypted load and miner/mobile claimRewards path pages wormhole transfers 300 at a time with order_by: {block: {height: asc}} and offset += 300. Height is not unique. When three or more transfers share a height that sits on a 300-row boundary, Postgres/Hasura returns one sibling twice and omits another. I ran the shipped WormholeUtxoService.getTransfersToMany against local Postgres using that exact SQL. 2439 rows came back, 2438 unique. 0000620674-d7dfe-000003 was duplicated. 0000620674-d7dfe-000011 (2.59 QUAN) was missing. The same engine with order_by: [{block.height: asc}, {id: asc}] returned all 2439. The official disk cache then writes cachedUpToBlock = tip-180 and later only asks height_gt that cutoff, so the missed finalized height is never asked again. forceReload deletes the cache and walks the same query, so it misses the same id. claimRewards calls getUnspentTransfers and never clears the cache. Official Encrypted Available / claim is short that note for as long as the app is the only exit.
Vulnerability Details
Wormhole notes are keyless. The only official spend path is: list inbound transfer rows, compute nullifiers, prove an exit. If a row never enters that list, the secret still owns the leaf and the official client cannot spend it.
The list is built here:
// quantus_sdk/lib/src/services/wormhole_utxo_service.dart:128-130
static const int _transferPageSize = 300;
static const int _nullifierBatchSize = 300;
static const int _reorgDepth = 180;No id. No leaf_index. Hasura compiles the nested order_by to a LEFT JOIN LATERAL on block.height and ORDER BY b.height ASC NULLS FIRST LIMIT 300 OFFSET n. Postgres is explicit that LIMIT/OFFSET without a unique ORDER BY does not partition the table. Adjacent pages can overlap one tied row and drop another.
_fetchAllTransfers is a plain offset walk (:366-394):
First claim / Encrypted load starts with an empty cache, so afterBlock = 0 and the whole history is walked that way.
What the official walk does to a 300-boundary tie
A dest with 2439 inbound rows and three transfers at height 620674 sitting at unique-order positions 598, 599, 600:
0000620674-d7dfe-000003
620674
2590000000000 (2.59 QUAN)
1439401
598
0000620674-d7dfe-000007
620674
2590000000000
1439402
599
0000620674-d7dfe-000011
620674
2590000000000
1439403
600
Height-only OFFSET 300 walk (official getTransfersToMany, local Postgres, 3/3):
fetched 2439
unique 2438
DUP
0000620674-d7dfe-000003MISS
0000620674-d7dfe-000011
Same engine, ORDER BY height, id: 2439/2439, miss present.
The official service then maps transfers to UTXOs by nullifier. A duplicate id is the same TC, so it collapses to one UTXO. A missing id never becomes a UTXO. Their own log on this run:
Why reload and the next claim do not heal it
After the first walk the service writes the on-disk cache (:555-562):
safeCutoff = chainHeight - 180. Height 620674 is far below that. The missed row's height is treated as already complete.
The next getTransfersToMany only asks height_gt: cachedUpToBlock. It never re-reads 620674. The comment above that write (:522-525) says the filter "correctly fetches only what we don't already have." That is true only if the first walk was a partition. It was not.
claimRewards (wormhole_send_service.dart:250-258) calls getUnspentTransfers and does not call forceReload or clearCachesForAddresses. Miner claim and mobile redeem both go through that method.
Encrypted pull-to-refresh does discardCachedState then load, which is clearCachesForAddresses plus the same getUnspentUtxos. That is a new empty-cache walk of the same height-only query. It misses the same id again.
Live dests (same query, official pin)
I am not putting those queries in run.sh. The program forbids testing on public nets. Independently of the local e2e, the verbatim official query against https://sub2.quantus.com/v1/graphql did the same thing:
dest
qzpmV14n66LkzgdHyDo2WvfxY5K2sZGcF1T6upuKiiNv2sHcA: 2439/2438, DUP…-000003, MISS…-000011(2.59 QUAN), 3/3. Unique order complete.is_deposit_only = true(wormhole dest, expected).dest
qzo3oXnC19kndaqoH5tEZEXC8KwrqC1HjfQG8ghRfWmSbwwSo: later 9442/9441, DUP…-000015, MISS0000697569-cfb10-000027(2.52 QUAN, leaf 1601620, TC 3600). Unique order complete. The miss survived +85 new inbound rows.
Planck tip at that check was 905417, safeCutoff = 905237. Both missed heights are finalized.
Not every same-height group on a 300-boundary skips. dest1 has four such groups in unique order and only height 620674 actually dups/skips. The class is OFFSET plus a non-unique ORDER BY, not "every tie is dropped."
Encrypted load also batches every scanned HD address into one _in query. The 300-boundary is then on the combined stream, so a dest with fewer than 300 transfers of its own can still lose a sibling.
Impact Details
Selected in-scope impact (Apps / shared SC, same mapping as the official Encrypted-send fee report):
High: Temporary freezing of funds for at least 24 hours
Not Direct theft. Not permanent freeze / hardfork. Not a chain Critical.
Local run of the shipped WormholeUtxoService (no mock of the walk):
Unique {height,id}
Official height-only walk
Rows fetched
2439
2439
Unique ids
2439
2438
…-000003
once
twice
…-000011 (2.59 QUAN)
present
absent
getUnspentTransfers UTXOs
2439
2438
Spendable
2443.770000000000 QUAN
2441.180000000000 QUAN
Dest credited that note
n/a
never, official path
After cache write, a second official getTransfersToMany asks height_gt: 905237 and gets 0 new rows. Miss stays gone. clearCachesForAddresses plus a new walk misses the same id.
A phone-only or miner-app user cannot change the GraphQL order_by. Recovery is an app or SDK release. That is not a same-day fix. 24 hours is a floor.
Pre-deployment: there is no public circulating supply to multiply by a spot price. Funds at risk are every Encrypted / miner wormhole dest whose inbound count crosses a 300-boundary on a same-height group. Measured on the official pin (not in run.sh): 2.59 QUAN + 2.52 QUAN on two dests. This local run freezes 2.59 QUAN.
References
quantus_sdk/lib/src/services/wormhole_utxo_service.dart128-130 (_transferPageSize,_reorgDepth), 290-313 (query), 366-394 (_fetchAllTransfers), 522-562 (cacheheight_gt: cachedUpToBlock)quantus_sdk/lib/src/services/wormhole_send_service.dart145-163, 239-280 (claimRewards->getUnspentTransfers, no cache clear)quantus_sdk/lib/src/services/encrypted_account_service.dart190-246 (forceReload=discardCachedState+ samegetUnspentUtxos)quantus_sdk/lib/src/constants/app_constants.dart20 (official pinhttps://sub2.quantus.com/v1/graphql)miner-app/lib/features/withdrawal/claim_rewards_dialog.dart126-131mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart54-59Postgres
LIMIT/OFFSETrequires a uniqueORDER BYto partition: https://www.postgresql.org/docs/current/queries-limit.html
Fix on the wallet side: order_by: [{block: {height: asc}}, {id: asc}] (or leaf_index), or replace OFFSET with keyset pagination. After that, bump the transfer-cache version or drop wormhole_cache_*.json so a client that already wrote cachedUpToBlock re-walks old heights.
Proof of Concept
Local only. Quantus is Substrate plus a Flutter SDK, so there is no Hardhat/Foundry fork. This is the local-fork equivalent: shipped WormholeUtxoService against local Postgres running the Hasura LATERAL SQL for height-only order_by.
How to run
From the repo root. Dart >= 3.8. Homebrew postgresql@14 (initdb, pg_ctl, psql). Python 3 stdlib. No env vars.
Expected last line: F-3041 E2E PASS.
Verify the shipped service and prepare local infrastructure
Symlink sdk/lib/src/services/wormhole_utxo_service.dart to the shipped file. cmp it. Fail if order_by is no longer height-only or if _runClaimFlow starts clearing the cache.
initdb a throwaway cluster on 127.0.0.1:55432. Load seed.sql: 2439 dest rows, three 2.59 QUAN siblings at height 620674 in unique-order slots 598-600.
sdk/lib/src/services/wormhole_utxo_service.dart is a symlink. Pagination, cache merge, and getUnspentTransfers are the shipped class. Local pieces are only the 127.0.0.1 transport and a temp cache directory.
Run output (this tree)
Files
All of this lives in-tree. Paste below is the host that produced the log.
poc/F-3041/run.sh
poc/F-3041/seed.sql
poc/F-3041/local_graphql.py
poc/F-3041/sdk/pubspec.yaml
poc/F-3041/sdk/bin/poc.dart
Local transport / platform only (not the walk):
poc/F-3041/sdk/lib/src/services/network/redundant_endpoint.dart
poc/F-3041/sdk/lib/src/services/substrate_service.dart
poc/F-3041/sdk/lib/src/services/hd_wallet_service.dart
poc/F-3041/sdk/lib/src/rust/api/wormhole.dart
poc/F-3041/sdk/lib/src/utils/print.dart
poc/F-3041/stubs/path_provider/lib/path_provider.dart
poc/F-3041/stubs/flutter/lib/foundation.dart
Was this helpful?