Summary
Published Immunefi report #90460, the Chief finding in its group (IMM-WA-HIGH-90460, pp. 87-88 of the official Immunefi / Quantus Audit Competition Report PDF).
The first-party Quantus point-of-sale screen (PosQrScreen) permanently switches to Payment Received as soon as it receives a matching txWatch_transfer notification. On the node side (node/src/txwatch.rs), txWatch_transfer is emitted when an extrinsic enters the transaction pool’s ready queue (import_notification_stream()), before block inclusion, execution, or finality.
After triggering Payment Received on the merchant’s screen, the payer can submit a second valid ML-DSA-65-signed transaction with the same account nonce and a higher tip. The production Substrate transaction pool usurps the original invoice payment and retains only the replacement. Because PosQrScreen has no usurp, drop, or finality handler, the merchant’s POS stays permanently latched in Payment Received.
Root cause
- Pre-execution node notification (
node/src/txwatch.rs):- Consumes the transaction pool’s
import_notification_stream()as soon as a transaction becomes ready in the mempool. - Decodes transfer destinations and amounts from the call arguments (including nested utility calls) and broadcasts
txWatch_transferwith no inclusion or finality status.
- Consumes the transaction pool’s
- Irreversible UI state latch (
mobile-app/lib/v2/screens/pos/pos_qr_screen.dart):mobile-app/lib/services/tx_watch_service.dartforwardstxWatch_transferdirectly to the POS callback.pos_qr_screen.dartchecks onlyBigInt.tryParse(tx.amount) == widget.amountToken, cancels the payment timeout, sets_paidTransfer, updates the screen title toPayment Received, and exposesDoneandNew Charge.- The background history poller has no callback to clear
_paidTransferwhen the mempool transaction is replaced or fails.
Attack sequence & proof of concept
- Submit invoice transfer: Payer broadcasts a signed transfer matching the merchant’s invoice amount with nonce
0and tip0. - Trigger POS paid state: The transaction enters the ready pool,
txWatchemitstxWatch_transfer, and the merchant’sPosQrScreenlatches intoPayment Received. - Usurp in the mempool: Before block production, the payer broadcasts a self-transfer with the same signer and nonce
0, carrying a higher tip. - Invoice evicted: Runtime validation assigns both extrinsics the same pool
providestag and gives the higher-tip replacement higher priority. The production pool emitsusurped(invoice_hash, replacement_hash)and drops the invoice transfer.
Flutter widget test output against audit-comp-quantus-apps (d5fc4d6572ebc2244e95009a33f5da5048935867):
PosQrScreen latches Payment Received from a doomed txWatch_transfer
[PosQr] onTransfer ... amount=5000000000000 hash=0xdoomedbatchall
[PendingTxPoller] no match yet ... will retry
PosQrScreen ignores a txWatch_transfer with the wrong amount
[PosQr] onTransfer ... amount=1 hash=0xother
[PosQr] amount mismatch ... ignoring
00:02 +2: All tests passed!
Impact
Any payer can cause the official merchant POS terminal to display Payment Received and completion controls while remotely replacing the unconfirmed transfer in the mempool before execution, allowing goods or services to be obtained without payment.
Recommended fix
Treat txWatch_transfer strictly as an unconfirmed “payment detected” indicator. Only transition PosQrScreen into Payment Received after observing a finalized block containing a successful Balances.Transfer event matching the merchant address, asset, and invoice amount.