Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
L-04·audit-finding

purchaseBlocked can miss a blocked treasury when a badge uses stored commission

Fixednfterc-1155marketplace
TL;DR

purchaseBlocked can miss a blocked treasury when a badge uses stored commission. Final disposition: Fixed.

Severity
LOW
Method
MManual review
CAT.

Tracker: issue #24 · Status: Fixed · Verified at: audit-v3-rereview-handoff

02Section · Affected files

Affected files

src/PlakxioSpecialMarket.sol#L355-L429

03Section · Description

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:

solidity
// SPDX-License-Identifier: MIT
pragma 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:

bash
forge test --match-path test/poc/PurchaseBlockedLiveVsStored.t.sol -vv

Observed output:

code
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.

04Section · Recommendations

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.

05Section · Remediation

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.

06Section · Verification provenance

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.

Status
Fixed
L-04