purchaseBlocked can miss a blocked treasury when a badge uses stored commission
purchaseBlocked can miss a blocked treasury when a badge uses stored commission. Final disposition: Fixed.
Tracker: issue #24 · Status: Fixed · Verified at: audit-v3-rereview-handoff
Affected files
src/PlakxioSpecialMarket.sol#L355-L429
Description
For a previously traded Special badge, _executePurchase calculates the treasury payout from the commission stored when the current holder bought the badge. purchaseBlocked, however, checks the treasury only when the current global commissionBps is nonzero, and its signature has no tokenId from which to read the stored terms.
If the global commission is reduced to zero after a badge has stored a nonzero commission, a blacklisted treasury is therefore omitted from the preflight result. The view returns (false, address(0)), but the purchase still calculates a nonzero treasury payout from the stored terms and reverts when transferring it.
Add the following test as test/poc/PurchaseBlockedLiveVsStored.t.sol:
// SPDX-License-Identifier: MITpragma solidity 0.8.30;import {AuthorizationFixture} from "../helpers/AuthorizationFixture.sol";import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import {PlakxioBadge} from "../../src/PlakxioBadge.sol";import {PlakxioSpecialMarket} from "../../src/PlakxioSpecialMarket.sol";import {MockBlacklistUSDC} from "../mocks/MockBlacklistUSDC.sol";contract PurchaseBlockedLiveVsStoredTest is AuthorizationFixture {PlakxioBadge internal badge;PlakxioSpecialMarket internal market;MockBlacklistUSDC internal usdc;address internal admin = makeAddr("admin");address internal minter = makeAddr("minter");address internal treasury = makeAddr("treasury");uint256 internal constant CLAIMER_KEY = 0xC444;uint256 internal constant BUYER_1_KEY = 0xB111;uint256 internal constant BUYER_2_KEY = 0xB222;address internal claimer = vm.addr(CLAIMER_KEY);address internal buyer1 = vm.addr(BUYER_1_KEY);address internal buyer2 = vm.addr(BUYER_2_KEY);uint256 internal constant TRADED_ID = 2_000_000_007;uint256 internal constant FRESH_ID = 2_000_000_008;uint256 internal constant INITIAL_FLOOR = 1e6;uint256 internal constant PROFIT_BPS = 1_000;uint256 internal constant COMMISSION_BPS = 500;function setUp() public {badge = new PlakxioBadge(admin, "uri", ADMIN_DELAY, SETTLEMENT_DELAY);usdc = new MockBlacklistUSDC();market = new PlakxioSpecialMarket(admin,badge,IERC20(address(usdc)),treasury,3_000,1_500,PROFIT_BPS,COMMISSION_BPS,INITIAL_FLOOR,1_000_000_000,ADMIN_DELAY);vm.startPrank(admin);badge.grantRole(badge.MINTER_ROLE(), minter);badge.setSettlementContract(address(market), true, badge.CLASS_MASK_SPECIAL());skip(3 days);vm.stopPrank();vm.startPrank(minter);badge.mintSpecial(claimer, TRADED_ID, 7);badge.mintSpecial(claimer, FRESH_ID, 8);vm.stopPrank();usdc.mint(buyer1, 1_000e6);usdc.mint(buyer2, 1_000e6);// Persist a 5% commission in TRADED_ID's terms._buySpecialAs(market, usdc, BUYER_1_KEY, TRADED_ID, claimer, INITIAL_FLOOR);}function test_baselineViewBlocksWhileLiveCommissionIsNonzero() public {usdc.setBlacklisted(treasury, true);(bool blocked, address blockedBy) = market.purchaseBlocked(buyer2, buyer1);assertTrue(blocked);assertEq(blockedBy, treasury);}function test_viewMissesStoredCommissionAndPurchaseRevertsAtomically() public {vm.prank(admin);market.setEscalationParams(PROFIT_BPS, 0);usdc.setBlacklisted(treasury, true);(bool blocked, address blockedBy) = market.purchaseBlocked(buyer2, buyer1);assertFalse(blocked);assertEq(blockedBy, address(0));uint256 payment = market.currentFloor(TRADED_ID);(PlakxioSpecialMarket.SpecialPurchase memory p,bytes memory sig,PlakxioSpecialMarket.PaymentAuthorization memory a) = _prepSpecialBuy(market, usdc, BUYER_2_KEY, TRADED_ID, buyer1, payment);uint256 buyerBefore = usdc.balanceOf(buyer2);uint256 holderBefore = usdc.balanceOf(buyer1);vm.expectRevert();market.buySpecialFor(p, sig, payment, a);assertEq(usdc.balanceOf(buyer2), buyerBefore);assertEq(usdc.balanceOf(buyer1), holderBefore);assertEq(badge.balanceOf(buyer1, TRADED_ID), 1);assertFalse(usdc.authorizationState(buyer2, a.nonce));// Restoring the treasury path makes the same signed purchase succeed.usdc.setBlacklisted(treasury, false);market.buySpecialFor(p, sig, payment, a);assertEq(badge.balanceOf(buyer2, TRADED_ID), 1);}function test_freshBadgeAtLiveZeroDoesNotPayTreasury() public {vm.prank(admin);market.setEscalationParams(PROFIT_BPS, 0);usdc.setBlacklisted(treasury, true);(bool blocked,) = market.purchaseBlocked(buyer2, claimer);assertFalse(blocked);uint256 treasuryBefore = usdc.balanceOf(treasury);_buySpecialAs(market, usdc, BUYER_2_KEY, FRESH_ID, claimer, market.currentFloor(FRESH_ID));assertEq(badge.balanceOf(buyer2, FRESH_ID), 1);assertEq(usdc.balanceOf(treasury), treasuryBefore);}}
Run:
forge test --match-path test/poc/PurchaseBlockedLiveVsStored.t.sol -vv
Observed output:
Ran 3 tests for test/poc/PurchaseBlockedLiveVsStored.t.sol:PurchaseBlockedLiveVsStoredTest[PASS] test_baselineViewBlocksWhileLiveCommissionIsNonzero() (gas: 53087)[PASS] test_freshBadgeAtLiveZeroDoesNotPayTreasury() (gas: 239703)[PASS] test_viewMissesStoredCommissionAndPurchaseRevertsAtomically() (gas: 300652)Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 14.43ms (4.11ms CPU time)Ran 1 test suite in 23.21ms (14.43ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
The failed purchase is atomic: buyer and holder balances, badge ownership, and the payment authorization remain unchanged. The same signed purchase succeeds after the treasury is unblacklisted. The practical impact is a misleading UI or preflight result and wasted transaction-submitter gas, not loss of user funds. The preconditions were not active in the current deployment snapshot, but remain reachable through a normal commission update and a change to the quote token's blacklist.
Recommendations
Add tokenId to purchaseBlocked and derive the effective commission through one helper shared with _executePurchase: use stored terms for a previously traded badge and the live commission for a never-traded badge. Migrate callers to the token-aware signature and remove the two-address version. If ABI compatibility requires a transition, make the legacy view conservatively check the treasury unconditionally and explicitly deprecate it so it cannot return this false negative.
This requires an ABI and client migration. A conservative legacy view also temporarily reports fresh, zero-commission badges as blocked when the treasury is blacklisted, so token-aware callers should be migrated promptly. Add regression tests for stored nonzero commission with live zero, a never-traded badge at live zero, and the ordinary live-nonzero case.
Remediation
Fixed. _termsFor reads the badge's stored terms, so the view no longer answers from live configuration, and purchase itself reverts TreasuryCannotReceive rather than leaving the caller to the view.
The fix changed the function's signature with no overload at the old selector. That fails safe but breaks off-chain callers silently, and the handoff's per-finding table does not note it — filed as #39.
Verification provenance
Final disposition: Fixed. Verified at audit-v3-rereview-handoff, commit f020448976eb7c4895e1626ef3228876cff2b7d2. This earlier remediation disposition is carried forward from the prior re-review, not independently re-verified in the generation-d pass. The report-wide scope and evidence limitations remain applicable; see the final report overview.