Gasless holders cannot submit the operator opt-out required by the trust model
Gasless holders cannot submit the operator opt-out required by the trust model. Final disposition: Fixed.
Tracker: issue #25 · Status: Fixed · Verified at: audit-v3-rereview-handoff
Affected files
src/PlakxioBadge.sol#L540-L584
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:
// SPDX-License-Identifier: MITpragma 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:
forge test --match-path test/poc/GaslessHolderOptOut.t.sol -vvv
Observed output:
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)
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.
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.
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.