From c3e0d1ec75ac17565c66400a8b9825821598dc92 Mon Sep 17 00:00:00 2001 From: LEE JUNSEO Date: Sat, 4 Jul 2026 17:34:29 +0900 Subject: [PATCH] Gate legacy element attestations behind the operator role Sanctions (A-01), AccreditedInvestor (A-03), and QualifiedPurchaser (A-13) had ungated, eventless attestation setters, diverging from the Governed/onlyOperator + event pattern used by the six CMP-001 elements (Jurisdiction, UsTaxResident, etc). Extend Governed on all three, gate setBlocked/setAccredited/setQp with onlyOperator, and emit an event per change. Lockup (C-01) has no settable mutator (state is injected via IAcquisitionSource), so it is left untouched. Add per-element outsider-auth-revert and set+emit tests to Elements.t.sol, written RED first against the unmodified elements. Existing fixtures (Engine.t.sol, IntegrationBase.sol, MultiRecipe.t.sol, SwapFlow.t.sol) call these setters from the deploying test contract itself, i.e. as owner, which onlyOperator already admits, so no fixture changes were needed. --- PROGRESS.md | 15 +++--- .../elements/AccreditedInvestor.sol | 8 ++- .../elements/QualifiedPurchaser.sol | 8 ++- src/compliance/elements/Sanctions.sol | 8 ++- test/unit/compliance/Elements.t.sol | 53 +++++++++++++++++++ 5 files changed, 79 insertions(+), 13 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ac9a228..1453f9f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -34,6 +34,9 @@ source of truth로 사용한다. `RFQMakerNotApproved`), maker-initiated nonce-scoped idempotent cancellation (`cancelQuoteNonce`/`cancelQuoteNonces`, `RFQQuoteCancelled`), venueType binding fix, `docs/rfq-threat-model.md` 위협 모델과 D007 결정 기록 +- legacy mock element(Sanctions A-01, AccreditedInvestor A-03, QualifiedPurchaser + A-13)의 attestation setter를 `Governed`/`onlyOperator` + 이벤트로 정렬해 CMP-001 + 이후 hardening divergence를 닫았다(Lockup C-01은 settable mutator가 없어 변경 없음). ## Blocked @@ -41,15 +44,13 @@ source of truth로 사용한다. ## Next -1. ungated legacy mock element(A-01 sanctions, A-03 accredited, QP)를 새 element와 - 동일하게 operator-gate로 정렬한다 — CMP-001 deferred follow-up. -2. RFQ integration-test 시나리오(router-path maker-approval/cancellation coverage)를 +1. RFQ integration-test 시나리오(router-path maker-approval/cancellation coverage)를 추가한다 — RFQ-002에서 deferred된 follow-up. -3. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 +2. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 (C-01 Lockup은 현재 fixture-only mock acquisition source). -4. live Anvil deployment/E2E를 추가한다. -5. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. -6. CI hardening(static analysis 등)을 강화한다. +3. live Anvil deployment/E2E를 추가한다. +4. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. +5. CI hardening(static analysis 등)을 강화한다. ## Last Session Summary diff --git a/src/compliance/elements/AccreditedInvestor.sol b/src/compliance/elements/AccreditedInvestor.sol index 5f03add..a5a4334 100644 --- a/src/compliance/elements/AccreditedInvestor.sol +++ b/src/compliance/elements/AccreditedInvestor.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.17; import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; import { ElementMetadata, ElementCategory, @@ -14,11 +15,13 @@ import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; /// @dev A-03-v1 Accredited investor attestation (mock). Settable per-user flag /// stands in for a real claim (existence + issuer + expiry, Pattern B). -contract AccreditedInvestor is BaseElement { +contract AccreditedInvestor is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-03-v1"; mapping(address => bool) public accredited; + event AccreditedInvestorSet(address indexed investor, bool isAccredited); + constructor() BaseElement(ElementMetadata({ elementId: ELEMENT_ID, @@ -31,8 +34,9 @@ contract AccreditedInvestor is BaseElement { })) {} - function setAccredited(address user, bool isAccredited) external { + function setAccredited(address user, bool isAccredited) external onlyOperator { accredited[user] = isAccredited; + emit AccreditedInvestorSet(user, isAccredited); } function check(address user, address, address, uint256, bytes calldata) diff --git a/src/compliance/elements/QualifiedPurchaser.sol b/src/compliance/elements/QualifiedPurchaser.sol index e3cc899..827ab17 100644 --- a/src/compliance/elements/QualifiedPurchaser.sol +++ b/src/compliance/elements/QualifiedPurchaser.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.17; import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; import { ElementMetadata, ElementCategory, @@ -14,11 +15,13 @@ import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; /// @dev A-13-v1 Qualified purchaser attestation (mock). Activated conditionally /// by the 3(c)(7) fund recipe; settable per-user flag stands in for a claim. -contract QualifiedPurchaser is BaseElement { +contract QualifiedPurchaser is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-13-v1"; mapping(address => bool) public qp; + event QualifiedPurchaserSet(address indexed investor, bool isQp); + constructor() BaseElement(ElementMetadata({ elementId: ELEMENT_ID, @@ -31,8 +34,9 @@ contract QualifiedPurchaser is BaseElement { })) {} - function setQp(address user, bool isQp) external { + function setQp(address user, bool isQp) external onlyOperator { qp[user] = isQp; + emit QualifiedPurchaserSet(user, isQp); } function check(address user, address, address, uint256, bytes calldata) diff --git a/src/compliance/elements/Sanctions.sol b/src/compliance/elements/Sanctions.sol index 288ce6a..a00082a 100644 --- a/src/compliance/elements/Sanctions.sol +++ b/src/compliance/elements/Sanctions.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.17; import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; import { ElementMetadata, ElementCategory, @@ -14,11 +15,13 @@ import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; /// @dev A-01-v1 Sanctions screen (mock). Blocks listed users at the trade gate. /// Real list management is out of scope — a settable mapping stands in. -contract Sanctions is BaseElement { +contract Sanctions is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-01-v1"; mapping(address => bool) public blocked; + event SanctionsBlockedSet(address indexed account, bool blocked); + constructor() BaseElement(ElementMetadata({ elementId: ELEMENT_ID, @@ -31,8 +34,9 @@ contract Sanctions is BaseElement { })) {} - function setBlocked(address user, bool isBlocked) external { + function setBlocked(address user, bool isBlocked) external onlyOperator { blocked[user] = isBlocked; + emit SanctionsBlockedSet(user, isBlocked); } function check(address user, address, address, uint256, bytes calldata) diff --git a/test/unit/compliance/Elements.t.sol b/test/unit/compliance/Elements.t.sol index 30668c6..4ced749 100644 --- a/test/unit/compliance/Elements.t.sol +++ b/test/unit/compliance/Elements.t.sol @@ -17,6 +17,7 @@ import { Statefulness } from "../../../src/types/ComplianceTypes.sol"; import {Events} from "../../../src/libraries/Events.sol"; +import {Errors} from "../../../src/libraries/Errors.sol"; contract MockAcquisitionSource is IAcquisitionSource { mapping(bytes32 => uint64) internal _at; @@ -31,8 +32,15 @@ contract MockAcquisitionSource is IAcquisitionSource { } contract ElementsTest is Test { + // Mirrors each legacy element's *Set event signature for vm.expectEmit matching + // (solc 0.8.17 does not support qualified `ContractName.EventName` emit syntax). + event SanctionsBlockedSet(address indexed account, bool blocked); + event AccreditedInvestorSet(address indexed investor, bool isAccredited); + event QualifiedPurchaserSet(address indexed investor, bool isQp); + address internal user = address(0xA11CE); address internal asset = address(0xBEEF); + address internal stranger = address(0xDEAD); function test_sanctions_pass_and_fail_and_metadata() public { Sanctions s = new Sanctions(); @@ -54,6 +62,21 @@ contract ElementsTest is Test { assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); } + function test_sanctions_setBlocked_reverts_for_non_operator() public { + Sanctions s = new Sanctions(); + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setBlocked(user, true); + } + + function test_sanctions_setBlocked_updates_state_and_emits() public { + Sanctions s = new Sanctions(); + vm.expectEmit(true, false, false, true); + emit SanctionsBlockedSet(user, true); + s.setBlocked(user, true); + assertTrue(s.blocked(user)); + } + function test_accredited_pass_fail_metadata() public { AccreditedInvestor a = new AccreditedInvestor(); (bool passed,) = a.check(user, address(0), asset, 0, ""); @@ -70,6 +93,21 @@ contract ElementsTest is Test { assertEq(uint256(m.temporal), uint256(TemporalNature.ONE_TIME)); } + function test_accredited_setAccredited_reverts_for_non_operator() public { + AccreditedInvestor a = new AccreditedInvestor(); + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + a.setAccredited(user, true); + } + + function test_accredited_setAccredited_updates_state_and_emits() public { + AccreditedInvestor a = new AccreditedInvestor(); + vm.expectEmit(true, false, false, true); + emit AccreditedInvestorSet(user, true); + a.setAccredited(user, true); + assertTrue(a.accredited(user)); + } + function test_qp_pass_fail_metadata() public { QualifiedPurchaser q = new QualifiedPurchaser(); (bool passed,) = q.check(user, address(0), asset, 0, ""); @@ -84,6 +122,21 @@ contract ElementsTest is Test { assertEq(uint256(m.decidability), uint256(Decidability.ATTESTATION_BASED)); } + function test_qp_setQp_reverts_for_non_operator() public { + QualifiedPurchaser q = new QualifiedPurchaser(); + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + q.setQp(user, true); + } + + function test_qp_setQp_updates_state_and_emits() public { + QualifiedPurchaser q = new QualifiedPurchaser(); + vm.expectEmit(true, false, false, true); + emit QualifiedPurchaserSet(user, true); + q.setQp(user, true); + assertTrue(q.qp(user)); + } + function test_lockup_blocks_until_elapsed() public { MockAcquisitionSource src = new MockAcquisitionSource(); uint64 lockupSeconds = 100;