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 →
L-05·audit-finding

Gasless holders cannot submit the operator opt-out required by the trust model

Fixednfterc-1155marketplace
TL;DR

Gasless holders cannot submit the operator opt-out required by the trust model. Final disposition: Fixed.

Severity
LOW
Method
MManual review
CAT.

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

02Section · Affected files

Affected files

src/PlakxioBadge.sol#L540-L584

03Section · Description

Description

All holder actions are expected to be signature-delivered because holders are gasless EOAs. However, setSettlementOptOut records the refusal against _msgSender(), and the badge contract provides no signature-authorized alternative. A relayer therefore opts itself out instead of the holder, leaving the holder's badge transferable by an active ordinary settlement operator.

An admin must first whitelist and activate the operator. The intended settlement contract also moves badges only against signed orders, and order cancellation remains available. The issue specifically prevents the holder-refusal control from bounding a wrong or compromised ordinary-operator grant.

The following Foundry test uses a malicious ordinary operator to show that a direct holder call blocks the transfer, while the same call submitted by a relayer changes only the relayer's state and allows the operator to take the badge:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {PlakxioBadge} from "../../src/PlakxioBadge.sol";
contract MaliciousOrdinaryOperator {
function take(PlakxioBadge badge, address from, address to, uint256 id) external {
badge.safeTransferFrom(from, to, id, 1, "");
}
}
contract GaslessHolderOptOutTest is Test {
PlakxioBadge internal badge;
MaliciousOrdinaryOperator internal operator;
address internal admin = makeAddr("admin");
address internal minter = makeAddr("minter");
address internal holder = makeAddr("holder");
address internal recipient = makeAddr("recipient");
address internal relayer = makeAddr("relayer");
function setUp() public {
badge = new PlakxioBadge(admin, "uri", uint48(2 days), 2 days);
operator = new MaliciousOrdinaryOperator();
vm.startPrank(admin);
badge.grantRole(badge.MINTER_ROLE(), minter);
badge.setSettlementContract(address(operator), true, badge.CLASS_MASK_ORDINARY());
vm.stopPrank();
vm.prank(minter);
badge.mint(holder, 7, 1, 1, "");
skip(2 days);
}
function test_directHolderOptOutStopsTheOperator() public {
vm.prank(holder);
badge.setSettlementOptOut(address(operator), true);
vm.expectRevert(
abi.encodeWithSelector(
PlakxioBadge.HolderOptedOut.selector, holder, address(operator)
)
);
operator.take(badge, holder, recipient, 7);
}
function test_relayerCannotRecordTheHoldersRefusal() public {
vm.prank(relayer);
badge.setSettlementOptOut(address(operator), true);
assertTrue(badge.settlementOptOut(relayer, address(operator)));
assertFalse(badge.settlementOptOut(holder, address(operator)));
operator.take(badge, holder, recipient, 7);
assertEq(badge.balanceOf(recipient, 7), 1);
}
}

Run it from the repository root:

code
forge test --match-path test/poc/GaslessHolderOptOut.t.sol -vvv

Observed output:

code
Ran 2 tests for test/poc/GaslessHolderOptOut.t.sol:GaslessHolderOptOutTest
[PASS] test_directHolderOptOutStopsTheOperator() (gas: 52708)
[PASS] test_relayerCannotRecordTheHoldersRefusal() (gas: 90200)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 28.60ms (2.13ms CPU time)
Ran 1 test suite in 50.26ms (28.60ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
04Section · Recommendations

Recommendations

Add a relayed EIP-712 opt-out function whose signed payload binds the holder, operator, optedOut value, per-holder nonce, and deadline. Verify the holder's signature before updating the holder's state.

Have the direct and relayed setters use the same internal update function so both enforce the Special-class restriction. A direct update must also advance the holder's nonce to invalidate outstanding signatures, preventing an older signed optedOut = false message from re-enabling an operator. Add tests for a successful relayed opt-out, expired and replayed signatures, direct-update invalidation, and the Special-class guard.

05Section · Remediation

Remediation

Fixed. setSettlementOptOutFor gives the gasless holder the path: EIP-712, a per-(holder, operator) nonce advanced by an unpredictable jump, a deadline capped by MAX_AUTHORIZATION_LIFETIME, ERC-1271, and the scheme bound as signed so a later-code-bearing EOA cannot change interpretation. A stale optedOut = false cannot re-enable a refused operator.

What the refusal does not reach is Special, by #17's design — see #37 and #38.

06Section · 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
L-05