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

Retirement can leave historical market authority active

Fixednfterc-1155marketplace
TL;DR

Retirement can leave historical market authority active. Final disposition: Fixed.

Severity
MEDIUM
Method
MManual review
CAT.

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

02Section · Affected files

Affected files

script/retire_superseded.sh#L26-L59

03Section · Description

Description

retire_superseded.sh applies identities from the current deployment artifact to every historical market. It reads BADGE from the current PlakxioBadge entry and incorrectly reads RELAYER from .treasury, then queries and revokes those identities on each old market.

These identities are not stable across generations. A full base deployment creates a new badge, while treasury and relayer are separate configuration values. An old market remains bound to its own immutable badge(), and RELAYER_ROLE may contain historical or rotated members. Consequently, the script can observe a zero mask on the current badge and no role for the current treasury, skip both revocations, and print done. while the old badge and actual old relayer remain authorized.

Vulnerable Scenario:

  1. An old generation authorizes oldMarket on oldBadge, grants RELAYER_ROLE to oldRelayer, and receives a valid signed ask, bid, and buyer payment authorization.
  2. A later base deployment creates a new badge and replaces the current deployment artifact. The current treasury is not oldRelayer.
  3. The administrator runs retire_superseded.sh. The current badge reports a zero mask for oldMarket, and oldMarket reports that the current treasury lacks RELAYER_ROLE, so the script sends neither revocation and reports completion.
  4. If oldRelayer is compromised, it can submit the still-valid signed trade through oldMarket, which continues to use oldBadge and accepts the obsolete relayer.

Save the following as test/poc/RetirementIdentityPoC.t.sol and run forge test --match-path test/poc/RetirementIdentityPoC.t.sol --via-ir -vv:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {AuthorizationFixture} from "../helpers/AuthorizationFixture.sol";
import {MockUSDC} from "../mocks/MockUSDC.sol";
import {PlakxioBadge} from "../../src/PlakxioBadge.sol";
import {PlakxioOrderTypes} from "../../src/PlakxioOrderTypes.sol";
import {PlakxioSettlement} from "../../src/PlakxioSettlement.sol";
contract RetirementIdentityPoC is AuthorizationFixture {
function _order(address maker, PlakxioOrderTypes.Side side, address quote, uint256 salt)
internal
view
returns (PlakxioOrderTypes.Order memory)
{
return PlakxioOrderTypes.Order({
maker: maker,
side: side,
makerIsContract: false,
tokenId: 42,
price: 100e6,
minProceeds: 0,
quote: quote,
nonce: 0,
expiry: block.timestamp + 1 days,
salt: salt
});
}
function _sign(PlakxioSettlement market, uint256 key, PlakxioOrderTypes.Order memory order)
internal
view
returns (bytes memory)
{
(uint8 v, bytes32 r, bytes32 s) = vm.sign(key, market.orderDigest(order));
return abi.encodePacked(r, s, v);
}
function test_retirementUsesTheWrongGenerationIdentities() public {
address admin = makeAddr("admin");
address treasury = makeAddr("currentTreasury");
address oldRelayer = makeAddr("oldRelayer");
uint256 sellerKey = 0xA11CE;
uint256 buyerKey = 0xB0B;
address seller = vm.addr(sellerKey);
address buyer = vm.addr(buyerKey);
MockUSDC usdc = new MockUSDC();
PlakxioBadge oldBadge = new PlakxioBadge(admin, "old", 2 days, 60);
PlakxioBadge currentBadge = new PlakxioBadge(admin, "current", 2 days, 60);
PlakxioSettlement oldMarket = new PlakxioSettlement(
admin, oldBadge, IERC20(address(usdc)), treasury, 10e6, 500, 1e6, 2 days
);
vm.startPrank(admin);
oldBadge.setSettlementContract(address(oldMarket), true, oldBadge.CLASS_MASK_ORDINARY());
oldMarket.grantRole(oldMarket.RELAYER_ROLE(), oldRelayer);
oldBadge.grantRole(oldBadge.MINTER_ROLE(), admin);
vm.stopPrank();
skip(60);
vm.prank(admin);
oldBadge.mint(seller, 42, 1, 1, "");
usdc.mint(buyer, 1_000e6);
PlakxioOrderTypes.Order memory ask =
_order(seller, PlakxioOrderTypes.Side.Ask, address(usdc), 1);
PlakxioOrderTypes.Order memory bid =
_order(buyer, PlakxioOrderTypes.Side.Bid, address(usdc), 2);
bytes memory askSig = _sign(oldMarket, sellerKey, ask);
bytes memory bidSig = _sign(oldMarket, buyerKey, bid);
PlakxioSettlement.PaymentAuthorization memory auth =
_authorize(usdc, address(oldMarket), buyerKey, 100e6);
vm.startPrank(admin);
if (currentBadge.settlementClassMask(address(oldMarket)) != 0) {
currentBadge.setSettlementContract(address(oldMarket), false, 0);
}
if (oldMarket.hasRole(oldMarket.RELAYER_ROLE(), treasury)) {
oldMarket.revokeRole(oldMarket.RELAYER_ROLE(), treasury);
}
vm.stopPrank();
assertEq(oldBadge.settlementClassMask(address(oldMarket)), oldBadge.CLASS_MASK_ORDINARY());
assertTrue(oldMarket.hasRole(oldMarket.RELAYER_ROLE(), oldRelayer));
uint256 snapshot = vm.snapshotState();
vm.prank(oldRelayer);
oldMarket.settle(ask, askSig, bid, bidSig, 100e6, auth);
assertEq(oldBadge.balanceOf(buyer, 42), 1);
assertEq(usdc.balanceOf(buyer), 900e6);
assertEq(usdc.balanceOf(seller), 95e6);
assertEq(usdc.balanceOf(treasury), 5e6);
assertTrue(vm.revertToState(snapshot));
vm.startPrank(admin);
oldBadge.setSettlementContract(address(oldMarket), false, 0);
oldMarket.revokeRole(oldMarket.RELAYER_ROLE(), oldRelayer);
vm.stopPrank();
vm.prank(oldRelayer);
vm.expectRevert();
oldMarket.settle(ask, askSig, bid, bidSig, 100e6, auth);
}
function test_sameBadgeAndRelayerControlRetiresTheMarket() public {
address admin = makeAddr("admin");
address relayerAndTreasury = makeAddr("relayerAndTreasury");
MockUSDC usdc = new MockUSDC();
PlakxioBadge badge = new PlakxioBadge(admin, "same", 2 days, 60);
PlakxioSettlement market = new PlakxioSettlement(
admin, badge, IERC20(address(usdc)), relayerAndTreasury, 10e6, 500, 1e6, 2 days
);
vm.startPrank(admin);
badge.setSettlementContract(address(market), true, badge.CLASS_MASK_ORDINARY());
market.grantRole(market.RELAYER_ROLE(), relayerAndTreasury);
badge.setSettlementContract(address(market), false, 0);
market.revokeRole(market.RELAYER_ROLE(), relayerAndTreasury);
vm.stopPrank();
assertEq(badge.settlementClassMask(address(market)), 0);
assertFalse(market.hasRole(market.RELAYER_ROLE(), relayerAndTreasury));
}
}

Observed output (gas and timing omitted):

code
Ran 2 tests for test/poc/RetirementIdentityPoC.t.sol:RetirementIdentityPoC
[PASS] test_retirementUsesTheWrongGenerationIdentities()
[PASS] test_sameBadgeAndRelayerControlRetiresTheMarket()
Suite result: ok. 2 passed; 0 failed; 0 skipped

The control shows the same procedure succeeds when the badge and relayer identities match. Separately, source review shows that suppressed broadcast-discovery errors can produce an empty loop followed by done. when deployment history is unavailable; this secondary condition was not runtime-tested.

04Section · Impact

Impact

A compromised obsolete relayer can execute still-valid maker-signed trades after administrators were told that the generation was retired. In the reproduced 100 USDC trade, the buyer paid 100 USDC, the seller received 95 USDC, the treasury received 5 USDC, and the badge moved to the buyer through the obsolete market.

This is not arbitrary theft: valid ask and bid signatures, a live buyer payment authorization, ownership, an in-band price, an unexpired order, and residual historical badge and relayer authority are all required. No currently executable deployment was identified, so this report does not claim an active incident.

05Section · Recommendations

Recommendations

For each historical market, obtain its actual badge by calling badge() and revoke that market on the returned contract. Reconstruct the complete live RELAYER_ROLE membership from RoleGranted and RoleRevoked events starting at the market's deployment block, or from an authoritative append-only registry, and revoke every remaining member rather than substituting one configured address.

After each transaction confirms, require the historical badge to report isSettlement(market) == false and settlementClassMask(market) == 0, and verify that every reconstructed relayer lacks the role. Treat missing broadcast history, an empty historical-market set, incomplete event coverage, RPC or transaction failures, and failed postconditions as fatal; print done. only after every historical market passes these checks.

Future deployment artifacts should record each generation's market, badge, deployment block, and initial relayer grants. The retirement process must still replay later role events so rotation and multiple concurrent relayers are handled correctly.

06Section · Remediation

Remediation

Fixed. The historical operator set is derived from the broadcast history rather than from the current artifact, a missing or unreadable history is fatal rather than silent, and a run that retires nothing is treated as an error instead of a result. done. now prints only when every historical market passed.

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-06