For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

id
height
amount
leaf
TC

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-000003

  • MISS 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, MISS 0000697569-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.dart 128-130 (_transferPageSize, _reorgDepth), 290-313 (query), 366-394 (_fetchAllTransfers), 522-562 (cache height_gt: cachedUpToBlock)

  • quantus_sdk/lib/src/services/wormhole_send_service.dart 145-163, 239-280 (claimRewards -> getUnspentTransfers, no cache clear)

  • quantus_sdk/lib/src/services/encrypted_account_service.dart 190-246 (forceReload = discardCachedState + same getUnspentUtxos)

  • quantus_sdk/lib/src/constants/app_constants.dart 20 (official pin https://sub2.quantus.com/v1/graphql)

  • miner-app/lib/features/withdrawal/claim_rewards_dialog.dart 126-131

  • mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart 54-59

  • Postgres LIMIT/OFFSET requires a unique ORDER BY to 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.

1

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.

2

Start local GraphQL and RPC

Start a 127.0.0.1 GraphQL/RPC. Transfer pages are psql of the LATERAL SQL above. No Python sort. Nullifier queries return empty (nothing spent). chain_getHeader returns tip 905417 so safeCutoff = 905237.

3

Run the official empty-cache transfer walk

Call official getTransfersToMany([dest]) on an empty cache.

4

Walk with unique ordering for ground truth

Walk the same tables with order_by: [{height: asc}, {id: asc}] for ground truth.

5

Verify cache behavior

Call official getTransfersToMany again (cache + height_gt: 905237).

6

Verify force reload behavior

Official clearCachesForAddresses then walk again (forceReload).

7

Verify the claim entry point

Official getUnspentTransfers (the claimRewards entry).

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?