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.rsconsumes the transaction pool'simport_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.dartforwards atxWatch_transferto the POS callback.mobile-app/lib/v2/screens/pos/pos_qr_screen.dartchecks only thatBigInt.tryParse(tx.amount) == widget.amountToken.It cancels the payment timeout, assigns
_paidTransfer, changes the title toPayment Received, and exposesDoneandNew Charge.The background history poll has no callback or reference capable of clearing
_paidTransferwhen 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
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.
Recommended remediation
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
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
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?