A payee who cannot receive the quote token permanently strands the badge, because the payout gates the badge transfer
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.
Description
Both trading paths push the payout to their recipients unconditionally, and the badge leg runs only after the push succeeds:
// PlakxioSpecialMarket.buySpecialquote.safeTransferFrom(msg.sender, holder, payment - commission);if (commission > 0) {quote.safeTransferFrom(msg.sender, treasury, commission);}badge.safeTransferFrom(holder, msg.sender, tokenId, 1, "");
// PlakxioSettlement._executePaymentquote.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:
- A user holds a Special badge and is blacklisted by the token issuer, for reasons unrelated to this protocol.
- Any buyer calls
buySpecial. The class, balance, self-buy and floor checks pass and_floorOfis written, then the holder payout reverts. - Every subsequent buyer, at every price, reverts identically.
- The holder cannot move the badge:
safeTransferFromfrom their own address revertsNotSettlementContract, and no burn exists.
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.
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.
+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.
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.
Affected files
src/PlakxioSpecialMarket.sol#L153-L157andsrc/PlakxioSettlement.sol#L653-L663at commit5c38893