tradi3_ Kurt Landman
Public report archive

Quantus T3-008

Quantus POS accepts a replaceable pool transaction as payment before execution

The first-party Quantus POS screen latches into Payment Received upon seeing a mempool import notification (txWatch_transfer), allowing a payer to usurp the invoice transaction with a same-nonce, higher-tip replacement before block inclusion.

Severity
High
Outcome
paid
Competition
Audit Comp | Quantus
Official report ID
IMM-WA-HIGH-90460
Published

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

  1. 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_transfer with no inclusion or finality status.
  2. Irreversible UI state latch (mobile-app/lib/v2/screens/pos/pos_qr_screen.dart):
    • mobile-app/lib/services/tx_watch_service.dart forwards txWatch_transfer directly to the POS callback.
    • pos_qr_screen.dart checks only BigInt.tryParse(tx.amount) == widget.amountToken, cancels the payment timeout, sets _paidTransfer, updates the screen title to Payment Received, and exposes Done and New Charge.
    • The background history poller has no callback to clear _paidTransfer when the mempool transaction is replaced or fails.

Attack sequence & proof of concept

  1. Submit invoice transfer: Payer broadcasts a signed transfer matching the merchant’s invoice amount with nonce 0 and tip 0.
  2. Trigger POS paid state: The transaction enters the ready pool, txWatch emits txWatch_transfer, and the merchant’s PosQrScreen latches into Payment Received.
  3. Usurp in the mempool: Before block production, the payer broadcasts a self-transfer with the same signer and nonce 0, carrying a higher tip.
  4. Invoice evicted: Runtime validation assigns both extrinsics the same pool provides tag and gives the higher-tip replacement higher priority. The production pool emits usurped(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.

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.