Fix: Repair unclaimable-payment recovery in PP_Queue_v1#780
Conversation
The queue payment processor escrows funds into the module itself when a direct payment to the recipient fails. Both recovery paths were unable to release those escrowed funds: - They released tokens with safeTransferFrom(address(this), ...), which requires a self-allowance that is never set, so the call reverts for standard ERC20s. The module already holds the tokens, so use safeTransfer. - The user-claim path guarded on the caller's unclaimable balance but the internal _claimPreviouslyUnclaimable keyed on the passed receiver argument. Claiming to a replacement receiver therefore read an empty slot and left the original balance stranded. The internal function now keys on _msgSender() and uses the argument only as the payout destination. Inherited unchanged by PP_Queue_ManualExecution_v1, so both deployed variants are fixed. Adds regression tests that fail on the pre-fix code.
|
We deployed this to Ethereum mainnet via community-multisig upgrade, so the live fix is in ahead of the merge. Quick rundown of what happened. Scope ended up smaller than the PR text implies: only What i did:
Verified after the upgrade that the beacon implementation points to the new impl and that Piku's proxy So the chain side is done, this PR is just to keep the source in sync, can be merged to dev whenever. |
Summary
Repairs the unclaimable-payment recovery paths in the queue payment processor. When a direct payment to a recipient fails (e.g. the recipient is blacklisted on a blacklistable ERC20), the processor escrows the tokens inside the module and records them under
_unclaimableAmountsForRecipient. Both routes out of that escrow bucket were broken:claimPreviouslyUnclaimableandclaimPreviouslyUnclaimableToTreasuryreleased the escrowed tokens withsafeTransferFrom(address(this), dest, amount). For a standard ERC20 this spendsallowance[address(this)][address(this)], which is never set, so the call reverts. The module already holds the tokens (they were pulled into it on the failed-transfer path), so the correct primitive issafeTransfer(dest, amount).claimPreviouslyUnclaimableguarded on the caller's balance (unclaimable(client, token, _msgSender())) but the internal_claimPreviouslyUnclaimableread and deleted the slot of the passedreceiver_argument. Claiming to a replacement receiver therefore passed the guard on the caller's slot while paying out from the replacement's empty slot, transferring nothing and leaving the original balance stranded. The internal function now keys on_msgSender()and uses the argument purely as the payout destination, matching the canonicalPP_Simple_v2.PP_Queue_ManualExecution_v1inherits these functions unchanged, so both deployed variants are fixed by this change.amountPaidis intentionally not re-called in recovery: the queue already settles client accounting when it escrows the funds, so a second call would double-count.Context
Reported privately by an external security researcher. No live funds are at risk today ($0 currently sits in the affected escrow bucket); the defect is latent and only bites once a payment lands in the failed-transfer fallback. The fix was independently verified by an agent review and a Codex second opinion.
Changes
src/modules/paymentProcessor/PP_Queue_v1.sol:safeTransferFrom(address(this), ...)tosafeTransfer(...)in both recovery paths; key the internal user-claim on_msgSender().test/unit/modules/paymentProcessor/PP_Queue_v1.t.sol: three regression tests covering both defects (each fails on the pre-fix code), plus an updated internal-claim test that no longer encodes the buggy behavior.Deployment Steps
This upgrades two separate module beacons:
PP_Queue_v1andPP_Queue_ManualExecution_v1. Run every step once per module (substitute the module name).beacon,implementationandversion(minor, patch) for each module.forceUpgradeBeaconAndRestartImplementation(beacon, newImplementation, newMinorVersion, newPatchVersion), which skips the timelock.After both beacons are upgraded, verify each
beacon.implementation()matches the implementation from Step 2 andbeacon.version()reports the new minor version.Test Plan
Pre-merge checklist
forge buildsucceedsforge test --match-contract PP_Queue_v1_Testpassesforge test --match-contract PP_Queue_ManualExecution_v1passesPost-deployment verification
claimPreviouslyUnclaimablereleases the funds, including to a replacement receiver distinct from the caller.claimPreviouslyUnclaimableToTreasurysweeps an escrowed balance to the failed-orders treasury.