diff --git a/PROGRESS.md b/PROGRESS.md index 0e7cb6d..8cce4fe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -36,6 +36,9 @@ source of truth로 사용한다. `RFQMakerNotApproved`), maker-initiated nonce-scoped idempotent cancellation (`cancelQuoteNonce`/`cancelQuoteNonces`, `RFQQuoteCancelled`), venueType binding fix, `docs/rfq-threat-model.md` 위협 모델과 D008 결정 기록 +- 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 @@ -45,28 +48,25 @@ source of truth로 사용한다. 1. MVP RFQ demo backend milestone/user flow를 별도 문서·feature로 구체화한다. PR #29/#30이 머지되면 기존 live-Anvil E2E/CLI 경로를 재사용한다. -2. pending RFQ/E2E/CLI/BUIDL PR stack(#26/#27/#28/#29/#30/#35)이 머지된 뒤 +2. pending RFQ/E2E/CLI/BUIDL PR stack(#28/#29/#30/#35)이 머지된 뒤 roadmap과 feature 상태를 재조정한다. -3. ungated legacy mock element(A-01 sanctions, A-03 accredited, QP)를 새 element와 - 동일하게 operator-gate로 정렬한다 — CMP-001 deferred follow-up. -4. 남은 RFQ production policy를 별도 feature로 분리한다: custody, partial fill, +3. 남은 RFQ production policy를 별도 feature로 분리한다: custody, partial fill, production dealer/operator 책임. -5. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 +4. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 (C-01 Lockup은 현재 fixture-only mock acquisition source). -6. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. +5. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. ## Last Session Summary -- #26 merge preparation includes CMP-002 (Manifest Lifecycle & Operator Approval Flow) on top of current main. -- CMP-002 closes the manifest lifecycle state machine and engine default-deny path after CMP-001. -- RFQ-002, CMP-001, and RFQ-SDK records are preserved during this stacked merge reconciliation. +- #27 merge preparation includes legacy element gating on top of current main. +- Legacy mock element setters for A-01/A-03/A-13 are now operator-gated, closing the CMP-001 hardening divergence. +- RFQ-002, CMP-001, CMP-002, and RFQ-SDK records are preserved during this stacked merge reconciliation. - 실행한 검증: - original PR CI/checks passed before retarget - conflict reconciliation checked with `git diff --check` - 남은 리스크: - MVP HTTP/CLI backend는 아직 구현하지 않았다. - production signer custody, persistent nonce store, pricing, inventory/risk는 integrator/operator 책임이다. - - ungated legacy mock element(A-01/A-03/QP)와 새 operator-gated element 사이 hardening divergence는 follow-up으로 정렬한다. - C-01 Lockup은 fixture-only mock acquisition source에 의존한다. ## RFQ-002 Merge Note 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;