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-04·audit-finding

Retiring the old market before its replacement activates halts trading

Fixednfterc-1155marketplace
TL;DR

Retiring the old market before its replacement activates halts trading. Final disposition: Fixed.

Severity
MEDIUM
Method
MManual review
CAT.

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

02Section · Affected files

Affected files

script/UpgradeSettlement.s.sol#L71-L86 script/UpgradeSpecialMarket.s.sol#L106-L112 src/PlakxioBadge.sol#L364-L402

03Section · Description

Description

setSettlementContract() makes a newly registered market active only at block.timestamp + settlementActivationDelay, while revoking an old market takes effect immediately. However, UpgradeSettlement.s.sol instructs the operator to retire the old market immediately after verification, and UpgradeSpecialMarket.s.sol prints the same retirement command without checking whether the replacement is active.

If verification finishes before the activation delay, following this procedure leaves the old market revoked and the replacement inactive. Reauthorizing the old market does not restore service immediately because every grant starts a new activation delay. The default deployment delay is two days. The current Base Sepolia badge uses 120 seconds, so this is not a current incident when verification takes longer than two minutes.

The comment that the overlap is “measured in minutes” also does not hold for longer delays: either the old market is retired before activation and trading stops, or it remains authorized until the replacement activates.

Vulnerable Scenario: Add the following test as test/poc/UpgradeRetOutage.t.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {PlakxioBadge} from "../../src/PlakxioBadge.sol";
contract PocMover {
function move(PlakxioBadge badge, address from, address to, uint256 id) external {
badge.safeTransferFrom(from, to, id, 1, "");
}
}
contract UpgradeRetOutage is Test {
address internal holder = address(0xA11CE);
address internal buyer = address(0xB0B);
uint256 internal constant ID = 81;
uint256 internal constant DEFAULT_DELAY = 2 days;
function test_documented_outage() public {
PlakxioBadge badge = new PlakxioBadge(address(this), "uri", uint48(2 days), DEFAULT_DELAY);
badge.grantRole(badge.MINTER_ROLE(), address(this));
badge.mint(holder, ID, 1, 1, "");
PocMover oldMarket = new PocMover();
PocMover newMarket = new PocMover();
badge.setSettlementContract(address(oldMarket), true, badge.CLASS_MASK_ORDINARY());
skip(DEFAULT_DELAY + 1);
badge.setSettlementContract(address(newMarket), true, badge.CLASS_MASK_ORDINARY());
uint256 activeFrom = badge.settlementActiveFrom(address(newMarket));
skip(10 minutes);
badge.setSettlementContract(address(oldMarket), false, 0);
assertFalse(badge.isApprovedForAll(holder, address(oldMarket)));
assertFalse(badge.isApprovedForAll(holder, address(newMarket)));
vm.expectRevert(abi.encodeWithSelector(PlakxioBadge.NotSettlementContract.selector, address(oldMarket)));
oldMarket.move(badge, holder, buyer, ID);
vm.expectRevert(
abi.encodeWithSelector(PlakxioBadge.SettlementNotActiveYet.selector, address(newMarket), activeFrom)
);
newMarket.move(badge, holder, buyer, ID);
vm.warp(activeFrom);
newMarket.move(badge, holder, buyer, ID);
}
}

Run forge test --match-contract UpgradeRetOutage -vv.

Observed output:

code
[PASS] test_documented_outage()
Suite result: ok. 1 passed; 0 failed; 0 skipped
04Section · Impact

Impact

An operator following the documented upgrade procedure can halt all ordinary-market settlements, or all Special-market buyouts, for the remainder of the activation delay. No funds are lost, and service returns when the replacement activates, but the production default makes the outage last almost two days.

05Section · Recommendations

Recommendations

Split upgrades into registration and retirement stages. Print the replacement's settlementActiveFrom, keep its off-chain order admission closed, and make the retirement helper refuse to revoke the old market unless the replacement is whitelisted for the expected class and block.timestamp >= settlementActiveFrom(replacement). Then switch order admission and retire the old market as one procedure.

This keeps the old market authorized during the activation delay. Keeping the replacement's order book closed during that period avoids a meaningful cross-market cancellation window; the overlap should be documented as activation-delay-bound rather than “measured in minutes.”

06Section · Remediation

Remediation

Fixed. The upgrade is a two-stage operation with staged and promoted manifests, and the retirement refuses to run while an upgrade is staged but not promoted. The inversion the finding described — retiring the old market while the replacement was unpromoted — is no longer reachable.

07Section · 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-04