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

90460 w a high quantus pos accepts a replaceable pool transaction as payment before execution

Submitted on Aug 25th 2026 at 06:32:14 UTC by @Tradi3 for Audit Comp | Quantus

  • Report ID: #90460

  • Report Type: Websites & Apps

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app

  • Impacts:

    • Merchant POS irreversibly reports an invoice paid after the payer replaces it before execution, enabling goods or services to be released without payment

Description

Brief

The first-party Quantus POS screen permanently switches to Payment Received when it receives a matching txWatch_transfer. That notification is emitted when a transaction enters the ready pool, before execution or finality.

The payer can then submit a different, valid transaction with the same account nonce and a higher tip. The production transaction pool usurps the invoice payment and leaves only the replacement ready. The merchant's POS still shows Payment Received with Done and New Charge, even though the invoice transaction will not execute.

This is a supplemented resubmission of closed report #89358. The original report did not execute the mobile screen and did not prove a remote method for removing the observed payment from the pool. Both gaps are covered by the two runnable tests supplied here.

Vulnerability details

The chain-side notification is pre-execution:

  • node/src/txwatch.rs consumes the transaction pool's import_notification_stream() and obtains the ready transaction.

  • It decodes transfer destinations and amounts from the call arguments, including nested utility calls.

  • The notification contains no inclusion, dispatch-success, or finality field.

The scoped mobile app treats that signal as final payment:

  • mobile-app/lib/services/tx_watch_service.dart forwards a txWatch_transfer to the POS callback.

  • mobile-app/lib/v2/screens/pos/pos_qr_screen.dart checks only that BigInt.tryParse(tx.amount) == widget.amountToken.

  • It cancels the payment timeout, assigns _paidTransfer, changes the title to Payment Received, and exposes Done and New Charge.

  • The background history poll has no callback or reference capable of clearing _paidTransfer when the transaction is absent, replaced, or failed.

The screen also calls _buildPaidContent(...) without returning that widget, so the QR body remains visible. That rendering defect does not undo the paid state: the title and the completion controls still change to the explicit success flow, the timeout is cancelled, and the paid latch is not cleared.

Attacker path

1

Submit an invoice transfer

The payer signs an ordinary invoice transfer with nonce 0 and no tip.

2

Submit through the normal external transaction path

It enters the ready pool and emits the import notification consumed by txWatch.

3

Trigger the POS success state

The merchant's real PosQrScreen receives the matching notification and displays Payment Received, Done, and New Charge.

4

Submit a replacement

Before inclusion, the payer submits a different signed transfer with the same signer and nonce, but a higher tip.

5

Usurp the invoice transaction

Runtime validation gives both transactions the same pool dependency tag and gives the replacement higher priority. The production pool emits usurped(invoice, replacement), removes the invoice, and retains only the replacement ready.

6

Retain the false paid state

The POS has no usurp, failure, or finality handler and remains in the paid state. A merchant relying on the first-party POS can release goods or services without receiving the invoice payment.

No miner, validator, RPC compromise, reorg, privileged role, or invalid transaction is required. Both transactions are real ML-DSA-65-signed Quantus extrinsics submitted as TransactionSource::External.

Closure reasons addressed

The original closure said the PoC never exercised the mobile POS. The client test now pumps the actual PosQrScreen, speaks the actual WebSocket JSON-RPC shape, and asserts the visible state transition. A wrong-amount control proves that the transition is caused by the matching invoice notification.

The original closure also treated remote eviction and merchant reliance as unverified. The chain test now submits two signed external transactions through the frozen runtime validator and production pool. It proves the invoice import notification occurs first, then proves the payer-controlled replacement removes that invoice and records the exact usurp event.

The repository documentation correctly warns that txWatch_transfer is not a confirmation. The defect reported here is not that the warning is absent or that the node emits a zero-confirmation signal. The defect is that the in-scope, first-party POS violates that documented integration rule and labels the signal as payment received without later correction.

Impact details

The payer can make the official merchant POS complete its success flow, replace the invoice before execution, and receive goods or services without the merchant receiving payment. The loss scales with the invoice value accepted by the merchant.

Quantus stated during the competition that it would reopen and classify this as High if a remote way to force the payment out of the mempool were demonstrated. The signed external replacement PoC satisfies that stated condition. This report requests High on that basis. It does not reuse the unrelated Changing sensitive details of other users impact selected on the original report.

Treat txWatch_transfer as payment detected, not payment received. Do not enter the paid state until a finalized block contains a successful transfer event matching the merchant, asset, and invoice amount. If an optimistic state is retained, subscribe to replacement/failure/finality status and clear it when the observed transaction is usurped, dropped, invalid, or unsuccessful.

Also return _buildPaidContent(...) from the paid branch so the screen cannot show a success title and completion controls alongside the original QR body.

Proof of Concept

Two tests reproduce the complete path on the frozen competition revisions.

A. Real first-party POS transition

Apps revision: d5fc4d6572ebc2244e95009a33f5da5048935867

PoC file: mobile-app/test/screens/pos_qr_mempool_paid_test.dart

Local source prepared for attachment:

C:\Users\Administrator\bounty-work\immunefi-quantus-competition-20260812\sources\audit-comp-quantus-apps\mobile-app\test\screens\pos_qr_mempool_paid_test.dart

SHA-256: 5571A6C9EF837328BCFCD4C60C05749E0666C04F68A9358B97D99FE51CC30CCB

1

Clone and check out the apps revision

2

Create the local .env file

The public checkout expects a .env asset. An empty local file is sufficient.

3

Run the Flutter test

Fresh observed result:

The first assertion uses the real PosQrScreen. It starts at Scan to Pay, receives the actual txWatch_transfer JSON shape over a local WebSocket, then renders Payment Received, Done, and New Charge. The mocked history lookup returns no transaction and the screen remains paid. The control sends the wrong amount and the screen does not enter the paid state.

B. Signed remote replacement in the production pool

Chain revision: 3b243b870a5442a6e5f443056f67bbba87d472f5

PoC file: client/transaction-pool/src/pos_mempool_replacement_poc.rs

Local source prepared for attachment:

C:\Users\Administrator\bounty-work\immunefi-quantus-competition-20260812\sources\audit-comp-quantus-chain\client\transaction-pool\src\pos_mempool_replacement_poc.rs

SHA-256: 4B5D63D1E9F0E921BAECAAA0A8E3C9FC5E72F4388909042FFE0D0375017C3008

1

Register the test module

Register the test module in client/transaction-pool/src/lib.rs:

2

Add development dependencies

Add these existing workspace crates under client/transaction-pool/Cargo.toml [dev-dependencies]:

3

Run the Cargo test

Fresh observed result:

The test uses real signed UncheckedExtrinsics, the frozen Quantus runtime validator, TransactionSource::External, the production pool import stream, and its actual replacement logic. It proves the first notification is emitted while the invoice is ready and that a later payer-controlled replacement removes that exact invoice before execution.

Complete PoC source A: pos_qr_mempool_paid_test.dart

Copy this exact file to mobile-app/test/screens/pos_qr_mempool_paid_test.dart before running the Flutter command above.

Complete PoC source B: pos_mempool_replacement_poc.rs

Copy this exact file to client/transaction-pool/src/pos_mempool_replacement_poc.rs, register it and add the listed dev-dependencies, then run the Cargo command above.

Was this helpful?