Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0008·denial-of-service

A payee who cannot receive the quote token permanently strands the badge, because the payout gates the badge transfer

Acknowledgednfterc-1155marketplace
TL;DR

Payouts are pushed to their recipients before the badge moves, so a payee who cannot receive the quote token blocks the badge transfer. Partially addressed: the client declined the recommended escrow and showed the stranding is reversible rather than permanent.

Severity
MEDIUM
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
MEDIUM
02Section · Description

Description

Both trading paths push the payout to their recipients unconditionally, and the badge leg runs only after the push succeeds:

solidity
// PlakxioSpecialMarket.buySpecial
quote.safeTransferFrom(msg.sender, holder, payment - commission);
if (commission > 0) {
quote.safeTransferFrom(msg.sender, treasury, commission);
}
badge.safeTransferFrom(holder, msg.sender, tokenId, 1, "");
solidity
// PlakxioSettlement._executePayment
quote.safeTransfer(seller, executionPrice - fee);
if (fee > 0) {
quote.safeTransfer(treasury, fee);
}

The quote currency is a Circle FiatToken, whose Blacklistable module reverts any transfer to or from a blacklisted address. A payee who cannot receive therefore does not merely miss a payment — they block the badge transfer that the payment gates.

For a Special holder this is terminal. buySpecial is their only exit: the order book rejects Special ids, PlakxioBadge admits no wallet-to-wallet transfer, no burn is exposed, and neither trading contract has a rescue function. Their badge can never be sold, moved, or destroyed by anyone, including the admin. _floorOf freezes at its last value and the ascending mechanic ends for that token.

The loss is not the blacklisted quote balance. It is a separate asset the blacklist reaches through the payment path.

The ordinary-settlement leg has the same shape with the same permanence — a blacklisted seller cannot sell their badge through any route. The treasury leg has the same shape with a different blast radius: since flatMinFee is non-zero at deploy, fee > 0 on every ordinary settlement, so a blacklisted treasury reverts every settlement and every commission-bearing Special sale at once. That variant is recoverable, in two setTreasury calls; the payee variants are not recoverable at all. All three share one fix, which is why they are reported together.

Vulnerable Scenario:

  1. A user holds a Special badge and is blacklisted by the token issuer, for reasons unrelated to this protocol.
  2. Any buyer calls buySpecial. The class, balance, self-buy and floor checks pass and _floorOf is written, then the holder payout reverts.
  3. Every subsequent buyer, at every price, reverts identically.
  4. The holder cannot move the badge: safeTransferFrom from their own address reverts NotSettlementContract, and no burn exists.
03Section · Impact

Impact

A blacklisted Special holder permanently loses the badge as an asset — not only its liquidity. A blacklisted ordinary seller is in the same position for their badge. In both cases no role can recover the state. When the blacklisted address is the treasury instead, both markets halt simultaneously for as long as the rotation takes, and per the documented failure policy every matched pair failing during that window costs both makers their orders.

04Section · Recommendation

Recommendation

Make the payee legs non-blocking so they can never block the badge leg. The badge transfer, not the payout, is the step with no alternative route.

diff
+mapping(address account => uint256) public owed;
+
+function _payOrCredit(IERC20 quote, address from, address to, uint256 amount) internal {
+ if (amount == 0) return;
+ try quote.transferFrom(from, to, amount) returns (bool ok) {
+ if (ok) return;
+ } catch {}
+ quote.safeTransferFrom(from, address(this), amount);
+ owed[to] += amount;
+}
+
+function withdrawProceeds(address to) external {
+ uint256 amount = owed[msg.sender];
+ owed[msg.sender] = 0;
+ quoteCurrency.safeTransfer(to, amount);
+}

with buySpecial's two pushes and _executePayment's seller and treasury pushes routed through it. withdrawProceeds takes a destination so a payee who cannot receive at their current address can still route the funds somewhere that can.

State the consequence for the documented invariant explicitly rather than leaving it implied: this makes the contract hold a balance across transactions, so "platform contracts never hold user funds outside an atomic transaction" needs restating as "never holds funds other than proceeds credited to a named payee and withdrawable only by them". That is a real change to the property and should be a deliberate decision rather than a side effect of the fix.

If holding balances is unacceptable, the narrower alternative for the ordinary path alone is a maker-signed payoutTo field in the order, letting a seller direct proceeds to a clean address. It does not cover buySpecial, which takes no seller signature, so the Special stranding would remain open.

05Section · Resolution

Resolution

Partially addressed; the recommended escrow was declined, and one element of this finding was overstated. The client declined to credit and hold proceeds on a non-custody basis: balances of that kind would exist by construction only for restricted parties, and a withdraw-to-any-address path is a mechanism for routing value on behalf of one. That is an owner decision and we do not dispute it.

The finding also described the stranding as permanent. It is not — a restriction is reversible, and the badge trades normally once it lifts. Suspended rather than destroyed, and the client has pinned that with a test.

What was built instead is a legibility layer: a restricted buyer or holder is now refused by name and up front, and a single view answers both so a client can disable the action rather than discovering the state by simulation. The token remains the guarantee; the added checks can only make a refusal legible, never permissive.

06Section · Affected files

Affected files

  • src/PlakxioSpecialMarket.sol#L153-L157 and src/PlakxioSettlement.sol#L653-L663 at commit 5c38893
F-2026-0008