diff --git a/FEATURES.md b/FEATURES.md index 4707df4..4f86b6b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -462,3 +462,48 @@ passing - `check`는 엔진이 direction-aware가 아니라는 점을 도움말·표기로 명시(asset-side 라벨). - `snapshot`/`restore`는 anvil 전용; `restore`는 이후 스냅샷을 무효화(문서화). - Non-goals: CLI-001과 동일(프로덕션 key 관리, out/ ABI 커플링, 2차 web3 라이브러리). + +## CMP-003 — Wave-2 Illustrative Elements (A-08/A-09/A-11/B-03/B-04/D-01) + +### Behavior + +- 6개의 신규 illustrative mock element를 element library에 추가한다: A-08 + EntityEligibility(entity-level AI/QP 자격 판정, look-through consumer), A-09 + EquityOwnerLookThrough(recursive ownership-graph 결과 기록, + `ILookThroughSource` 구현), A-11 ClaimFreshness(AI/QP claim의 + verifiedAt/issuerExpiry freshness gate), B-03 TransferRestrictionMetadata + (asset-side 양도제한 legend 선언의 완전성/일관성 검사), B-04 + EngineSelection(trade-context 기반 허용 execution engine 게이트; + `ComplianceContext`를 디코딩하는 유일한 element), D-01 HolderCount(STATEFUL; + §12(g)/§3(c)(1)/506(b) holder-count cap). +- 각 element는 기존 illustrative library와 동일한 패턴을 따른다: + `BaseElement`(D-01은 `BaseStatefulElement`) + operator-gated attestation + setter + 전용 Foundry unit test. +- `script/DeployStack.s.sol`이 6개 element 전부를 `ElementRegistry`에 등록한다: + A-09를 먼저 배포해 그 주소를 A-08 생성자에 `ILookThroughSource`로 주입하고, + D-01은 A-04(IdentityUniqueness)/A-03(AccreditedInvestor) 주소와 + `CapMode.TWELVE_G`로 생성한 뒤 engine 생성 이후 `setEngine`으로 post-trade + write path를 연결한다(F-02 SurveillanceFlag와 동일 패턴). +- 이 wave는 element library 확장에 한정된다: 어떤 recipe의 + `requiredElements`에도 연결하지 않는다(recipe 부착은 별도 feature). + +### Verification + +- 개별 unit test: `test/unit/compliance/elements/{EntityEligibility, + EquityOwnerLookThrough,ClaimFreshness,TransferRestrictionMetadata, + EngineSelection,HolderCount}.t.sol`. +- `forge build --offline`. +- `forge test --offline`(전체 399/399 유지; `DeployStack.s.sol`은 forge test + 경로에서 실행되지 않으므로 등록 변경으로 인한 기존 test 회귀 없음). +- `forge fmt script/DeployStack.s.sol`. + +### State + +passing + +### Notes + +- Non-goals: 이번 wave의 recipe/manifest 연결(어떤 recipe도 새 element를 + 요구하지 않음), production legal 활성화, look-through graph 실제 순회. +- Deferred follow-up: 6개 element를 실제 recipe(`requiredElements`)에 연결할지, + 연결한다면 어떤 recipe에 붙일지는 별도 feature에서 결정한다. diff --git a/PROGRESS.md b/PROGRESS.md index b73e894..6dc840b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -60,6 +60,13 @@ source of truth로 사용한다. `snapshot`/`restore`, `quote-inspect`(서명자 복구/만료/nonce·승인, 실패 시 exit 1). reason 디코딩·EIP-712 복구는 기존 lib 재사용. smoke(quote-inspect valid+tampered) + full live walkthrough로 검증. `forge test --offline` 238/238 유지. +- `CMP-003 — Wave-2 Illustrative Elements`(A-08 EntityEligibility, A-09 + EquityOwnerLookThrough, A-11 ClaimFreshness, B-03 TransferRestrictionMetadata, + B-04 EngineSelection, D-01 HolderCount 6개 mock element + 전용 unit test): + `script/DeployStack.s.sol`에 전부 등록(A-09 → A-08 생성자 주입, D-01은 + A-04/A-03 주소 + `CapMode.TWELVE_G`로 생성 후 engine 생성 이후 `setEngine`). + 어떤 recipe의 `requiredElements`에도 연결하지 않음(별도 feature로 이연). + `forge test --offline` 399/399 유지. ## Blocked @@ -77,28 +84,34 @@ source of truth로 사용한다. 5. 실제 Uniswap v3 pool 배포를 demo/E2E에 연결한다(현재 AMM venue는 MockPool; `tools/deploy-v3` vendor isolation 유지). 6. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. +7. CMP-003의 6개 wave-2 element(A-08/A-09/A-11/B-03/B-04/D-01)를 실제 recipe + `requiredElements`에 연결할지, 연결한다면 어떤 recipe에 붙일지 결정한다. ## Last Session Summary -- #35 merge preparation includes `DEMO-001 — BUIDL-like ERC-3643 Demo Asset` on top of current main. +- `CMP-003 — Wave-2 Illustrative Elements`: A-08/A-09/A-11/B-03/B-04/D-01 + 6개 mock element(사전 세션에서 개별 구현 + unit test 완료)를 + `script/DeployStack.s.sol`에 등록하는 integration 작업. - 변경한 파일: - - `src/demo/BuidlLikeDemoAsset.sol` - - `src/compliance/elements/BuidlMinimumInvestment.sol` - - `src/compliance/recipes/BuidlLikeFundRecipe.sol` - - `test/fixtures/MockSecuritizeTA.sol` - - `test/integration/IntegrationBase.sol` - - `test/integration/BUIDLLikeFlow.t.sol` - - `docs/product-specs/buidl-like-demo-profile.md` + - `script/DeployStack.s.sol` - `FEATURES.md`, `PROGRESS.md` - 완료한 작업: - - BUIDL-like ERC-3643 demo asset profile 추가 - - Mock Securitize/TA fixture로 investor facts를 주입하고 Element flags로 sync - - QP/minimum investment/sanctions/expiry/ERC-3643 recipient verification 시나리오 추가 - - 최신 Manifest lifecycle에 맞춰 BUIDL-like manifest도 register→approve flow로 정합화 + - A-09(EquityOwnerLookThrough)를 먼저 배포하고 그 주소를 A-08 + (EntityEligibility) 생성자에 `ILookThroughSource`로 주입 + - A-11(ClaimFreshness), B-03(TransferRestrictionMetadata), + B-04(EngineSelection)를 문서화된 생성자 인자로 배포·등록 + - D-01(HolderCount)을 위해 A-04(IdentityUniqueness)/A-03(AccreditedInvestor) + 생성 결과를 state 변수로 캡처하도록 최소 리팩터링하고, `CapMode.TWELVE_G` + + 두 주소로 생성 후 engine 생성 이후 `setEngine`으로 post-trade write path + 연결(F-02 SurveillanceFlag와 동일 패턴) + - recipe `requiredElements`는 의도적으로 변경하지 않음(별도 feature로 이연) - 실행한 검증: - - original PR CI/checks passed before retarget - - conflict reconciliation checked with `git diff --check` + - `forge build --offline` + - `forge test --offline`(전체 399/399 유지; 기존 test 조정 불필요 — + `DeployStack.s.sol`은 forge test 경로에서 실행되지 않음) + - `forge fmt script/DeployStack.s.sol` - 남은 리스크: - - 실제 BlackRock BUIDL/Securitize/TA 연결은 구현 범위가 아니다. - - AI/QP는 아직 production ONCHAINID claim이 아니라 mock TA에서 sync되는 test flag다. - - NAV, redemption rail, monthly distribution, production claim issuer 연동은 별도 feature다. + - 6개 element는 아직 어떤 recipe에도 연결되지 않아 trade-path 동작이 + 검증되지 않았다(단위 테스트만 존재). + - `DeployStack.s.sol`의 실제 라이브 Anvil 배포/CLI 경로 재실행(`scripts/e2e-anvil.sh`)은 + 이번 세션에서 수행하지 않았다. diff --git a/script/DeployStack.s.sol b/script/DeployStack.s.sol index cfdd902..0229e3d 100644 --- a/script/DeployStack.s.sol +++ b/script/DeployStack.s.sol @@ -24,6 +24,12 @@ import {AssetClassification} from "../src/compliance/elements/AssetClassificatio import {Erc3643Native} from "../src/compliance/elements/Erc3643Native.sol"; import {FormDFiling} from "../src/compliance/elements/FormDFiling.sol"; import {Lockup} from "../src/compliance/elements/Lockup.sol"; +import {EntityEligibility} from "../src/compliance/elements/EntityEligibility.sol"; +import {EquityOwnerLookThrough} from "../src/compliance/elements/EquityOwnerLookThrough.sol"; +import {ClaimFreshness} from "../src/compliance/elements/ClaimFreshness.sol"; +import {TransferRestrictionMetadata} from "../src/compliance/elements/TransferRestrictionMetadata.sol"; +import {EngineSelection} from "../src/compliance/elements/EngineSelection.sol"; +import {HolderCount, IIdentityView, IAiView} from "../src/compliance/elements/HolderCount.sol"; import {IAcquisitionSource} from "../src/interfaces/compliance/IAcquisitionSource.sol"; import {RegD506cRecipe} from "../src/compliance/recipes/RegD506cRecipe.sol"; import {Fund3c7Recipe} from "../src/compliance/recipes/Fund3c7Recipe.sol"; @@ -82,6 +88,13 @@ contract DeployStack is Script, TREXCore, DemoConstants { Lockup internal lockup; DemoAcquisitionSource internal acqSource; + // wave-2 elements (illustrative library extension; kept in variables only + // where a later constructor/wiring step needs the address). + IdentityUniqueness internal identityUniqueness; + AccreditedInvestor internal accreditedInvestor; + EquityOwnerLookThrough internal equityOwnerLookThrough; + HolderCount internal holderCountElement; + ExecutionRouter internal router; VenueRegistry internal venueReg; VenueSelector internal selector; @@ -136,6 +149,7 @@ contract DeployStack is Script, TREXCore, DemoConstants { ammAdapter.setRouter(address(router)); rfqAdapter.setRouter(address(router)); surveillance.setEngine(address(engine)); + holderCountElement.setEngine(address(engine)); // 6. tokens + AMM pool (token0=QUOTE, token1=RWA). quote = new MockERC20("Quote USD", "qUSD"); @@ -204,8 +218,10 @@ contract DeployStack is Script, TREXCore, DemoConstants { elementReg.registerElement(bytes32("A-01-v1"), address(new Sanctions())); jurisdiction = new Jurisdiction(); elementReg.registerElement(bytes32("A-02-v1"), address(jurisdiction)); - elementReg.registerElement(bytes32("A-03-v1"), address(new AccreditedInvestor())); - elementReg.registerElement(bytes32("A-04-v1"), address(new IdentityUniqueness())); + accreditedInvestor = new AccreditedInvestor(); + elementReg.registerElement(bytes32("A-03-v1"), address(accreditedInvestor)); + identityUniqueness = new IdentityUniqueness(); + elementReg.registerElement(bytes32("A-04-v1"), address(identityUniqueness)); elementReg.registerElement(bytes32("A-05-v1"), address(new UsTaxResident())); assetClass = new AssetClassification(REG_D_CLASS); elementReg.registerElement(bytes32("B-01-v1"), address(assetClass)); @@ -219,6 +235,27 @@ contract DeployStack is Script, TREXCore, DemoConstants { surveillance = new SurveillanceFlag(); elementReg.registerElement(bytes32("A-13-v1"), address(new QualifiedPurchaser())); elementReg.registerElement(bytes32("F-02-v1"), address(surveillance)); + + // --- wave 2: A-08/A-09/A-11/B-03/B-04/D-01 (illustrative library + // extension; NOT wired into any recipe's requiredElements — see + // FEATURES.md). A-09 must exist before A-08 so its address can be + // injected as A-08's ILookThroughSource seam. + equityOwnerLookThrough = new EquityOwnerLookThrough(); + elementReg.registerElement(bytes32("A-09-v1"), address(equityOwnerLookThrough)); + elementReg.registerElement(bytes32("A-08-v1"), address(new EntityEligibility(equityOwnerLookThrough))); + elementReg.registerElement(bytes32("A-11-v1"), address(new ClaimFreshness())); + elementReg.registerElement(bytes32("B-03-v1"), address(new TransferRestrictionMetadata())); + elementReg.registerElement(bytes32("B-04-v1"), address(new EngineSelection())); + // D-01 is STATEFUL and needs the A-04/A-03 addresses above plus a cap + // regime; TWELVE_G is the demo-sensible default (the other two modes + // are library completeness, doc HolderCount.sol). Engine wiring + // happens later, once `engine` exists (mirrors surveillance.setEngine). + holderCountElement = new HolderCount( + HolderCount.CapMode.TWELVE_G, + IIdentityView(address(identityUniqueness)), + IAiView(address(accreditedInvestor)) + ); + elementReg.registerElement(bytes32("D-01-v1"), address(holderCountElement)); } /// @dev Full investor-side engine attestations for the 9-element recipe. diff --git a/src/compliance/elements/ClaimFreshness.sol b/src/compliance/elements/ClaimFreshness.sol new file mode 100644 index 0000000..0e2439c --- /dev/null +++ b/src/compliance/elements/ClaimFreshness.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; + +/// @dev A-11-v1 Claim freshness (mock). Stands in for the on-chain freshness +/// gate on an already-issued AI/QP claim: A-03/A-13 judge claim +/// *substance* (is this person actually AI/QP); A-11 only judges whether +/// the claim's `verifiedAt` timestamp is still within its reuse window at +/// trade time. Pure deterministic timestamp arithmetic — no look-through, +/// no manual review path (doc §5.5, §6.3): the only cure for any failure +/// here is re-verification producing a fresh `verifiedAt`. +/// +/// Reason code numbers (element-doc §6.2 names), in `check()` evaluation +/// order: +/// 1 = FAIL_NO_VERIFIED_AT claim.verifiedAt unset +/// 2 = FAIL_CLAIM_STALE_AI AI claim older than CAP_AI (5y, statutory) +/// 3 = FAIL_CLAIM_STALE_QP QP claim older than CAP_QP (1y, POLICY) +/// 4 = FAIL_CLAIM_EXPIRED issuer-set expiry (shorter than the cap) passed +/// 5 = FAIL_UNKNOWN_CLAIM_TYPE claimType not AI/QP — fail-closed, never +/// defaults to the laxer AI cap +contract ClaimFreshness is BaseElement, Governed { + bytes32 internal constant ELEMENT_ID = "A-11-v1"; + + enum FreshClaimType { + UNKNOWN, + AI, + QP + } + + struct FreshnessClaim { + FreshClaimType claimType; + uint64 verifiedAt; // 0 = unset (no freshness anchor to compute from) + uint64 issuerExpiry; // 0 = none; if set and shorter than the regulatory/ + // policy cap, the issuer-set expiry wins (tighter always dominates) + } + + /// @dev Rule 506(c)(2)(ii)(E): a prior AI verification may be reused "for a + /// period of five years from the date the person was previously + /// verified" — this cap is STATUTORY, not a Decipher choice. + uint64 public constant CAP_AI = 5 * 365 days; + + /// @dev Decipher POLICY value — NOT statute. ICA §3(c)(7) requires QP + /// status only "at the time of acquisition" and contains no + /// re-verification or expiry provision at all; this 1-year window is + /// a conservative Decipher risk buffer, deliberately narrower than + /// the statutory 5-year AI window, because a single non-qualifying + /// holder collapses the entire fund's §3(c)(7) exemption. This value + /// must never be represented as a legal requirement in comments, + /// docs, or user-facing messages — it is a risk-management choice. + uint64 public constant CAP_QP = 365 days; + + /// @notice user => attested freshness claim. + mapping(address => FreshnessClaim) public claimOf; + + event ClaimSet(address indexed user, FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry); + + constructor() + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.INVESTOR_ATTRIBUTE, + version: "A-11-v1", + temporal: TemporalNature.PERIODIC, + decidability: Decidability.DETERMINISTIC, + timing: ObligationTiming.AT_TRADE_GATE, + statefulness: Statefulness.STATELESS + })) + {} + + /// @notice Records (or overwrites) the attested freshness anchor for `user`. + /// @dev `verifiedAt`/`issuerExpiry` are attested off-chain by the Trusted + /// Issuer that signed the underlying AI/QP claim; A-11 trusts them at + /// face value and performs only deterministic timestamp arithmetic on + /// top of that trust (doc §5.5, §8.3, §10.4 — liability for a wrong + /// `verifiedAt` sits with the issuer/attester, not with this element). + function setClaim(address user, FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry) + external + onlyOperator + { + claimOf[user] = FreshnessClaim({claimType: claimType, verifiedAt: verifiedAt, issuerExpiry: issuerExpiry}); + emit ClaimSet(user, claimType, verifiedAt, issuerExpiry); + } + + /// @dev doc §5.2 order, T_tx = block.timestamp (the settlement block, per + /// §5.4's recommendation): + /// 1. `verifiedAt == 0` -> FAIL_NO_VERIFIED_AT (no arithmetic anchor). + /// 2. `claimType == UNKNOWN` -> FAIL_UNKNOWN_CLAIM_TYPE, fail-closed + /// (never silently treated as the laxer AI cap). + /// 3. Pick the cap by type, then take the earlier of the + /// regulatory/policy expiry and any issuer-set expiry. + /// 4. Strict `>` against T_tx: exactly-at-cap PASSes (doc §5.3 — + /// the cap is an inclusive window, only exceeding it fails). + function check(address user, address, address, uint256, bytes calldata) + external + view + override + returns (bool passed, bytes32 reasonCode) + { + FreshnessClaim memory c = claimOf[user]; + + if (c.verifiedAt == 0) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + + if (c.claimType == FreshClaimType.UNKNOWN) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + + uint64 cap = c.claimType == FreshClaimType.QP ? CAP_QP : CAP_AI; + uint64 regulatoryExpiry = c.verifiedAt + cap; + bool issuerBinds = c.issuerExpiry != 0 && c.issuerExpiry < regulatoryExpiry; + uint64 effectiveExpiry = issuerBinds ? c.issuerExpiry : regulatoryExpiry; + + if (block.timestamp > effectiveExpiry) { + if (issuerBinds) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + uint32 staleCode = c.claimType == FreshClaimType.QP ? 3 : 2; + return (false, ReasonCodes.encode(0, ELEMENT_ID, staleCode)); + } + + return (true, bytes32(0)); + } +} diff --git a/src/compliance/elements/EngineSelection.sol b/src/compliance/elements/EngineSelection.sol new file mode 100644 index 0000000..b2638eb --- /dev/null +++ b/src/compliance/elements/EngineSelection.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness, + ComplianceContext, + VenueType +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; + +/// @dev B-04-v1 Engine selection (mock). Gates whether *this trade* may settle on +/// *this engine* for *this asset* — the "how is it sold" axis of resale +/// compliance (Rule 144(f) manner-of-sale + §4(a)(7) no-general-solicitation). +/// The engine (call path) IS the legal manner of sale (doc §5.4), so the gate +/// is a pure set-membership evaluation over three sealed inputs: the card's +/// declared engine set, the platform governance sets, and the trade context. +/// +/// This is the ONE element that decodes `context`: ComplianceEngine passes +/// `abi.encode(ComplianceContext)` as the element context (ComplianceEngine.sol +/// _runChecks). The engine identity is `ctx.venueType`, the affiliate flag is +/// `ctx.sellerIsAffiliate`, and the market-maker-claim subject is `ctx.buyer`. +/// +/// Engine bitmask convention MUST match `ManifestCore.supportedEngines`: +/// bit i == VenueType(i), i.e. AMM=bit0(0x01), ORDER_BOOK=bit1(0x02), +/// RFQ=bit2(0x04) (see ComplianceTypes.VenueType). NOTE: this differs from the +/// element walkthrough §3.16 (which sketched bit0=RFQ); the repo-wide manifest +/// convention is authoritative here, and the two RFQ-only governance sets are +/// initialised accordingly (RFQ_BIT). +/// +/// EVENT OMISSION: the doc (§5.2, §3.16) specifies a per-check `B04Check` +/// audit event. `check` is `view` (inherited IComplianceElement signature — +/// cannot be widened here), so emitting from it is impossible. The gate's +/// pass/fail semantics are preserved exactly; only the check-time event is +/// dropped. Setter events (governance-plane + card-declaration mutations) are +/// retained — those functions are non-view. +/// +/// Reason-code table (n => doc §6.2 failure-code name): +/// 1 = FAIL_ENGINE_DECL_MISSING (V1/G① — supportedEngines empty) +/// 2 = FAIL_ENGINE_DECL_INVALID (V2/G① — bit outside VALID_ENGINES) +/// 3 = FAIL_ENGINE_UNKNOWN (G② — engine unidentifiable) +/// 4 = FAIL_ENGINE_NOT_SUPPORTED (G③ — engine not in card set) +/// 5 = FAIL_ENGINE_PATH_INCOMPATIBLE (G④ — §4(a)(7) path overlay) +/// 6 = FAIL_ENGINE_AFFILIATE_INCOMPATIBLE(G⑤a — Rule 144(f) engine overlay) +/// 7 = FAIL_ENGINE_MM_CLAIM_MISSING (G⑤b — RFQ counterparty MM claim) +contract EngineSelection is BaseElement, Governed { + bytes32 internal constant ELEMENT_ID = "B-04-v1"; + + /// @dev All three legal engines: AMM|ORDER_BOOK|RFQ = 0x01|0x02|0x04. + uint8 internal constant VALID_ENGINES = 0x07; + /// @dev RFQ = VenueType(2) => bit2. The current AFFILIATE/NO_GS sets are {RFQ}. + uint8 internal constant RFQ_BIT = uint8(1) << uint8(VenueType.RFQ); + + /// @dev Length of `abi.encode(ComplianceContext)`. ComplianceContext is 11 + /// statically-sized fields (5 address, 2 uint256, 2 enum, 1 address, 1 + /// bool) => 11 words => 352 bytes, head-only (no dynamic tail). A context + /// shorter than this cannot be decoded, so it is caught before `abi.decode` + /// (which would revert) and mapped to the fail-closed code 3. + uint256 internal constant CTX_ENCODED_LEN = 352; + + /// @notice asset => declared supported-engines bitset (card declaration mock). + mapping(address => uint8) public supportedEnginesOf; + /// @notice asset => this trade routes the §4(a)(7) resale path (C-00 mock). + mapping(address => bool) public sec4a7PathOf; + /// @notice asset => security is a debt security (Rule 144(f)(3)(ii) carve-out). + mapping(address => bool) public isDebtSecurityOf; + /// @notice buyer => holds a MARKET_MAKER_STATUS claim (A-11-scoped mock). + mapping(address => bool) public mmClaimOf; + + /// @notice Governance constant — §4(a)(7) no-general-solicitation engine set. + /// Governance-plane input, not a card fact; widening is a legal + /// judgment gated off-chain (doc §11.2). Initialised to {RFQ}. + uint8 public noGsEngineSet; + /// @notice Governance constant — Rule 144(f) affiliate manner-of-sale set. + /// Initialised to {RFQ}; the (i)/(iii) branches stay closed until a + /// registered BD / SRO-reporting path exists (doc §3.4-3.7). + uint8 public affiliateEngineSet; + + event SupportedEnginesSet(address indexed asset, uint8 engines); + event Sec4a7PathSet(address indexed asset, bool active); + event DebtSecuritySet(address indexed asset, bool isDebt); + event MarketMakerClaimSet(address indexed buyer, bool hasClaim); + event NoGsEngineSetUpdated(uint8 engineSet); + event AffiliateEngineSetUpdated(uint8 engineSet); + + constructor() + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.RESALE_TRANSACTION, + version: "B-04-v1", + temporal: TemporalNature.REALTIME, + decidability: Decidability.DETERMINISTIC, + timing: ObligationTiming.AT_TRADE_GATE, + statefulness: Statefulness.STATELESS + })) + { + // Current legally-confirmed sets are both {RFQ} (doc §3.16, §3.4, §3.8). + noGsEngineSet = RFQ_BIT; + affiliateEngineSet = RFQ_BIT; + } + + /// @notice Declare (or clear) the supported-engines bitset for `asset`. + function setSupportedEngines(address asset, uint8 engines) external onlyOperator { + supportedEnginesOf[asset] = engines; + emit SupportedEnginesSet(asset, engines); + } + + /// @notice Set whether `asset`'s current trade routes the §4(a)(7) resale path. + function setSec4a7Path(address asset, bool active) external onlyOperator { + sec4a7PathOf[asset] = active; + emit Sec4a7PathSet(asset, active); + } + + /// @notice Set whether `asset` is a debt security (Rule 144(f)(3)(ii) carve-out). + function setDebtSecurity(address asset, bool isDebt) external onlyOperator { + isDebtSecurityOf[asset] = isDebt; + emit DebtSecuritySet(asset, isDebt); + } + + /// @notice Set whether `buyer` holds a market-maker-status claim. + function setMarketMakerClaim(address buyer, bool hasClaim) external onlyOperator { + mmClaimOf[buyer] = hasClaim; + emit MarketMakerClaimSet(buyer, hasClaim); + } + + /// @notice Update the §4(a)(7) no-general-solicitation engine set (governance). + function setNoGsEngineSet(uint8 engineSet) external onlyOperator { + noGsEngineSet = engineSet; + emit NoGsEngineSetUpdated(engineSet); + } + + /// @notice Update the Rule 144(f) affiliate engine set (governance). + function setAffiliateEngineSet(uint8 engineSet) external onlyOperator { + affiliateEngineSet = engineSet; + emit AffiliateEngineSetUpdated(engineSet); + } + + /// @notice Listing-time declaration check (doc §5.2 V1/V2), same codes 1/2 as + /// the runtime G① recheck. V3a (AMM venue-pool registration) and V3b + /// (fund + AMM acquirer-identity review) need VenueRegistry/manifest + /// access and live with the operator/factory layer — out of scope here. + function validateEngineDeclaration(address asset) external view returns (bool ok, bytes32 reasonCode) { + uint8 se = supportedEnginesOf[asset]; + if (se == 0) return (false, _code(1)); // V1 — missing declaration + if ((se & ~VALID_ENGINES) != 0) return (false, _code(2)); // V2 — unknown bit + return (true, bytes32(0)); + } + + /// @dev Per-trade gate G①→G⑤ in doc §5.2 order; first failure wins. `user`, + /// `counterparty`, `amount` are ignored — the subjects come from the + /// decoded ComplianceContext. All overlays only NARROW the declared set + /// (never open an undeclared engine), and when both the §4(a)(7) path + /// overlay (G④) and the affiliate overlay (G⑤) are active the engine must + /// satisfy BOTH — the two sequential fail-fast gates realise an + /// INTERSECTION, never a union (doc §5.3 last row). + function check(address, address, address asset, uint256, bytes calldata context) + external + view + override + returns (bool passed, bytes32 reasonCode) + { + // G① Card recheck — replay listing V1/V2 on the trade-time snapshot to + // catch drift between correction versions. SLOAD + bitmask only. + uint8 se = supportedEnginesOf[asset]; + if (se == 0) return (false, _code(1)); // FAIL_ENGINE_DECL_MISSING + if ((se & ~VALID_ENGINES) != 0) return (false, _code(2)); // FAIL_ENGINE_DECL_INVALID + + // G② Engine identification. The call path IS the engine (doc §5.4), and the + // engine hands it to us as abi.encode(ComplianceContext). A decoded + // VenueType enum can never be out of range, so code 3 is unreachable + // from a well-formed context. It is kept as a fail-closed safety pin: + // an empty / short (undecodable-length) context means the engine could + // not be identified — e.g. a deploy-order fault where a new engine ships + // before the context wiring — and fail-closed is the only safe direction + // (doc §5.2 G② rationale, §3.1 §5 default-is-prohibited). + if (context.length < CTX_ENCODED_LEN) return (false, _code(3)); // FAIL_ENGINE_UNKNOWN + + ComplianceContext memory ctx = abi.decode(context, (ComplianceContext)); + uint8 engineBit = uint8(1) << uint8(ctx.venueType); + + // G③ Declaration membership — enforce the card's declared engine set. + if ((engineBit & se) == 0) return (false, _code(4)); // FAIL_ENGINE_NOT_SUPPORTED + + // G④ §4(a)(7) path overlay — that path requires a no-general-solicitation + // engine (§77d(d)(2)). Asymmetric implication: only SEC4A7 constrains; + // a Rule 144 non-affiliate path has no manner/solicitation axis. + if (sec4a7PathOf[asset] && (engineBit & noGsEngineSet) == 0) { + return (false, _code(5)); // FAIL_ENGINE_PATH_INCOMPATIBLE + } + + // G⑤ Affiliate overlay — Rule 144(b)(2)/(f) manner-of-sale. Rule + // 144(f)(3)(ii) exempts debt securities entirely; omitting this + // `!isDebtSecurityOf` branch would over-block affiliate debt sales. + if (ctx.sellerIsAffiliate && !isDebtSecurityOf[asset]) { + // G⑤a engine must be in the affiliate manner-of-sale set. + if ((engineBit & affiliateEngineSet) == 0) { + return (false, _code(6)); // FAIL_ENGINE_AFFILIATE_INCOMPATIBLE + } + // G⑤b RFQ-to-market-maker requires the counterparty's MM claim + // (§3(a)(38); the only off-chain-attested atom in this element). + if (ctx.venueType == VenueType.RFQ && !mmClaimOf[ctx.buyer]) { + return (false, _code(7)); // FAIL_ENGINE_MM_CLAIM_MISSING + } + } + + return (true, bytes32(0)); + } + + function _code(uint32 n) private pure returns (bytes32) { + return ReasonCodes.encode(0, ELEMENT_ID, n); + } +} diff --git a/src/compliance/elements/EntityEligibility.sol b/src/compliance/elements/EntityEligibility.sol new file mode 100644 index 0000000..8391e4a --- /dev/null +++ b/src/compliance/elements/EntityEligibility.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {ILookThroughSource, LookThroughStatus} from "../../interfaces/compliance/ILookThroughSource.sol"; + +/// @dev A-08-v1 Buyer-side entity-level eligibility (mock). Stands in for the +/// off-chain Trusted-Issuer determination of whether a *buyer entity* meets +/// the entity-level requirements of the qualification a trade demands — +/// accredited-investor (R1, Reg D 506(c)) and/or qualified-purchaser (R3, +/// ICA 3(c)(7)) — and, where a category qualifies only through its members, +/// routes to the equity-owner look-through element (A-09) via the INJECTED +/// ILookThroughSource seam. All entity facts (category, investments, +/// formed-for-purpose, QIB status, per-category direct reqs) are attested +/// off-chain and written as a claim by an operator; `check` is a pure, +/// deterministic evaluation of the stored claim in the doc §5.2 order. +/// +/// Natural persons are out of scope (A-03/A-13 decide them directly): a +/// buyer whose claim is not `isEntity` is dormant (PASS). Which track(s) +/// the trade actually requires is a per-asset, trade-context decision the +/// operator writes into `requiredTracksOf` (doc §5.4); the element only +/// enforces what is switched on — a token's past issuance framework does +/// NOT auto-arm the AI track. +/// +/// Comparison operators are load-bearing (doc §5.3) and fixed as part of +/// each threshold's meaning: the AI direct-asset $5M is STRICT `>` (exactly +/// $5,000,000 FAILs); the QP family-company $5M and institutional $25M are +/// INCLUSIVE `>=` (exactly at threshold PASSes). A single `>=` for all +/// three would mis-decide the AI boundary to the cent. +/// +/// Reason code map — `n` in `ReasonCodes.encode(0, ELEMENT_ID, n)` -> doc +/// §6.2 failure-code name: +/// 1 | ENTITY_CATEGORY_MISMATCH (claimed basis not in an active track) +/// 2 | ENTITY_THRESHOLD_NOT_MET (assets/investments below threshold, op applied) +/// 3 | ENTITY_QIB_UNCONFIRMED (QP_QIB path, QIB status not confirmed) +/// 4 | ENTITY_FORMED_FOR_PURPOSE (AI direct path barred / QP trust no-cure) +/// 5 | ENTITY_LOOKTHROUGH_REQUIRED (A-09 status NONE/PENDING — wait, not reject) +/// 6 | ENTITY_LOOKTHROUGH_FAILED (A-09 found a non-qualifying member) +/// 7 | ENTITY_DIRECT_REQ_MISSING (per-category extra req not attested) +/// 8 | ENTITY_AND_GATE_FAIL (R1 and R3 both active, at least one fails) +contract EntityEligibility is BaseElement, Governed { + bytes32 internal constant ELEMENT_ID = "A-08-v1"; + + // 501(a)(3)/(7)/(9)/(12) direct-asset path collapsed into DIRECT_ASSETS; (a)(8) = ALL_OWNERS_AI. + enum AiBasis { + NONE, + DIRECT_ASSETS, + ALL_OWNERS_AI + } + + // 2(a)(51)(A)(ii) family company | (iv) institutional | 2a51-1(g)(1) QIB | (iii) trust. + enum QpBasis { + NONE, + FAMILY_COMPANY, + INSTITUTIONAL, + QIB, + TRUST + } + + struct EntityClaim { + bool isEntity; // false => dormant for this buyer (natural persons: A-03/A-13) + AiBasis aiBasis; + QpBasis qpBasis; + uint256 investmentsUsd; // whole USD, off-chain computed per Rule 2a51-1(b)-(f) + bool formedForPurpose; // "formed for the specific purpose of acquiring" — verifier-attested + bool qibConfirmed; // Rule 144A QIB status + account capacity + carve-out, verifier-confirmed + bool directReqsMet; // per-category extra reqs (sophistication/knowledgeability/family/discretion) + } + + /// @dev Track activation bits for `requiredTracksOf`. bit0 = R1/AI, bit1 = R3/QP. + uint8 internal constant TRACK_AI = 1; + uint8 internal constant TRACK_QP = 2; + + /// @dev Thresholds with their comparison operator baked into meaning (doc §5.3). + uint256 internal constant AI_DIRECT_MIN_USD = 5_000_000; // PASS iff investments > 5M (STRICT) + uint256 internal constant QP_FAMILY_MIN_USD = 5_000_000; // PASS iff investments >= 5M (INCLUSIVE) + uint256 internal constant QP_INSTITUTIONAL_MIN_USD = 25_000_000; // PASS iff investments >= 25M (INCLUSIVE) + + /// @dev Thrown if constructed with a zero look-through source: the ALL_OWNERS_AI, + /// QP_TRUST and formed-for-purpose company paths cannot resolve without it. + error ZeroLookThroughSource(); + + /// @notice Injected A-09 seam — settled recursive look-through outcome per subject. + ILookThroughSource public immutable lookThroughSource; + + /// @notice buyer => attested entity claim (default: isEntity=false => dormant). + mapping(address => EntityClaim) public claims; + + /// @notice asset => required tracks bitmask (bit0=R1/AI, bit1=R3/QP; 0 = dormant). + /// Per-asset, trade-context activation decided off-chain (doc §5.4). + mapping(address => uint8) public requiredTracksOf; + + // Enum params canonicalize to uint8 in the event signature; tests re-declare + // with uint8 to match (Solidity 0.8.17 cannot `emit` a non-library contract's + // event by qualified name; that needs >=0.8.22). + event EntityClaimSet( + address indexed user, + bool isEntity, + AiBasis aiBasis, + QpBasis qpBasis, + uint256 investmentsUsd, + bool formedForPurpose, + bool qibConfirmed, + bool directReqsMet + ); + event RequiredTracksSet(address indexed asset, uint8 tracks); + + /// @dev Per-track diagnostic for the AND-gate (doc §6.4 buyer-facing category + /// vs internal field diagnosis). `check` is `view` per IComplianceElement + /// and cannot emit — and a view call in the gating path would not persist + /// logs anyway — so the per-track codes underlying a code-8 verdict are + /// surfaced through the non-view `diagnose` companion instead. + event EntityAndGateDiagnostic(address indexed user, address indexed asset, uint32 aiCode, uint32 qpCode); + + constructor(ILookThroughSource lookThroughSource_) + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.INVESTOR_ATTRIBUTE, + version: "A-08-v1", + temporal: TemporalNature.ONE_TIME, + decidability: Decidability.ATTESTATION_BASED, + timing: ObligationTiming.EX_ANTE_VERIFY, + statefulness: Statefulness.STATELESS + })) + { + if (address(lookThroughSource_) == address(0)) { + revert ZeroLookThroughSource(); + } + lookThroughSource = lookThroughSource_; + } + + /// @notice Writes the operator-attested entity claim for `user`. + function setEntityClaim(address user, EntityClaim calldata claim) external onlyOperator { + claims[user] = claim; + emit EntityClaimSet( + user, + claim.isEntity, + claim.aiBasis, + claim.qpBasis, + claim.investmentsUsd, + claim.formedForPurpose, + claim.qibConfirmed, + claim.directReqsMet + ); + } + + /// @notice Sets the per-asset required-tracks bitmask (bit0=R1/AI, bit1=R3/QP). + function setRequiredTracks(address asset, uint8 tracks) external onlyOperator { + requiredTracksOf[asset] = tracks; + emit RequiredTracksSet(asset, tracks); + } + + /// @dev Buyer-facing gate. `user` is the buyer entity; `asset` selects the + /// active tracks. Returns the first failing reason code; code 8 when both + /// tracks are active and either fails (per-track detail via `diagnose`). + function check(address user, address, address asset, uint256, bytes calldata) + external + view + override + returns (bool passed, bytes32 reasonCode) + { + (bool aiActive, bool qpActive, uint32 aiCode, uint32 qpCode, bool dormant) = _tracksAndCodes(user, asset); + (bool ok, uint32 code) = _verdict(aiActive, qpActive, aiCode, qpCode, dormant); + return (ok, ok ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, code)); + } + + /// @notice Same verdict as `check`, but emits the per-track diagnostic for an + /// AND-gate failure so the underlying codes reach the audit log. This + /// is the on-chain home for the doc §5.2 step-3 diagnostic event that + /// a `view` `check` cannot emit. + function diagnose(address user, address asset) external returns (bool passed, bytes32 reasonCode) { + (bool aiActive, bool qpActive, uint32 aiCode, uint32 qpCode, bool dormant) = _tracksAndCodes(user, asset); + (bool ok, uint32 code) = _verdict(aiActive, qpActive, aiCode, qpCode, dormant); + if (aiActive && qpActive && (aiCode != 0 || qpCode != 0)) { + emit EntityAndGateDiagnostic(user, asset, aiCode, qpCode); + } + return (ok, ok ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, code)); + } + + /// @dev Resolves which tracks are active for `asset` and each active track's + /// first-failure code (0 = ok). `dormant` short-circuits to PASS when no + /// track is required or the buyer is not an entity (doc §5.2 step 0-1). + function _tracksAndCodes(address user, address asset) + internal + view + returns (bool aiActive, bool qpActive, uint32 aiCode, uint32 qpCode, bool dormant) + { + uint8 tracks = requiredTracksOf[asset]; + if (tracks == 0) return (false, false, 0, 0, true); + + EntityClaim memory c = claims[user]; + if (!c.isEntity) return (false, false, 0, 0, true); + + aiActive = tracks & TRACK_AI != 0; + qpActive = tracks & TRACK_QP != 0; + if (aiActive) aiCode = _checkAi(user, c); + if (qpActive) qpCode = _checkQp(user, c); + } + + /// @dev Combines the per-track outcomes (doc §5.2 step 3). Both tracks active + /// => AND (both must pass, else code 8). Single track => its own code. + function _verdict(bool aiActive, bool qpActive, uint32 aiCode, uint32 qpCode, bool dormant) + internal + pure + returns (bool passed, uint32 code) + { + if (dormant) return (true, 0); + if (aiActive && qpActive) { + if (aiCode == 0 && qpCode == 0) return (true, 0); + return (false, 8); + } + uint32 single = aiActive ? aiCode : qpCode; + return (single == 0, single); + } + + /// @dev AI (R1) track. Order: category -> formed-for-purpose (direct path + /// barred, cure is (a)(8) reclassification) -> STRICT `>` threshold -> + /// (a)(8) member look-through -> per-category direct req. + function _checkAi(address user, EntityClaim memory c) internal view returns (uint32) { + AiBasis b = c.aiBasis; + if (b == AiBasis.NONE) return 1; + + if (b == AiBasis.DIRECT_ASSETS) { + // (a)(3)/(7)/(9)/(12) carry "not formed for the specific purpose" in + // the statute itself: a formed-for-purpose entity cannot pass the + // direct path at all; its only cure is reclassifying to (a)(8). + if (c.formedForPurpose) return 4; + if (c.investmentsUsd <= AI_DIRECT_MIN_USD) return 2; // STRICT `>`: exactly 5M FAILs + } else { + // ALL_OWNERS_AI ((a)(8)) — no formed-for-purpose bar; qualifies only + // through every equity owner being AI, confirmed by A-09. + LookThroughStatus s = lookThroughSource.statusOf(user); + if (s == LookThroughStatus.NONE || s == LookThroughStatus.PENDING) return 5; + if (s == LookThroughStatus.FAILED) return 6; + } + + if (!c.directReqsMet) return 7; + return 0; + } + + /// @dev QP (R3) track. Order: category -> trust (formed-for-purpose has NO + /// cure; else member look-through) / family|institutional (INCLUSIVE + /// `>=` threshold; formed-for-purpose cured by 2a51-3 look-through + /// COMPLETED) / QIB (status) -> per-category direct req. + function _checkQp(address user, EntityClaim memory c) internal view returns (uint32) { + QpBasis b = c.qpBasis; + if (b == QpBasis.NONE) return 1; + + if (b == QpBasis.TRUST) { + // §2(a)(51)(A)(iii): "not formed for the specific purpose" is a + // statute requirement, and 2a51-3's all-owners-QP cure covers (ii)/(iv) + // companies only — a formed-for-purpose trust has NO look-through cure. + if (c.formedForPurpose) return 4; + LookThroughStatus s = lookThroughSource.statusOf(user); + if (s == LookThroughStatus.NONE || s == LookThroughStatus.PENDING) return 5; + if (s == LookThroughStatus.FAILED) return 6; + } else if (b == QpBasis.FAMILY_COMPANY || b == QpBasis.INSTITUTIONAL) { + uint256 min = b == QpBasis.FAMILY_COMPANY ? QP_FAMILY_MIN_USD : QP_INSTITUTIONAL_MIN_USD; + if (c.investmentsUsd < min) return 2; // INCLUSIVE `>=`: exactly at threshold PASSes + if (c.formedForPurpose) { + // 2a51-3(a): a formed-for-purpose (ii)/(iv) company qualifies only + // if every beneficial owner is a QP (A-09 look-through COMPLETED). + LookThroughStatus s = lookThroughSource.statusOf(user); + if (s == LookThroughStatus.NONE || s == LookThroughStatus.PENDING) return 5; + if (s == LookThroughStatus.FAILED) return 6; + } + } else { + // QIB (2a51-1(g)(1)) — deemed QP with no investments computation. + if (!c.qibConfirmed) return 3; + } + + if (!c.directReqsMet) return 7; + return 0; + } +} diff --git a/src/compliance/elements/EquityOwnerLookThrough.sol b/src/compliance/elements/EquityOwnerLookThrough.sol new file mode 100644 index 0000000..e4a78ca --- /dev/null +++ b/src/compliance/elements/EquityOwnerLookThrough.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {ILookThroughSource, LookThroughStatus} from "../../interfaces/compliance/ILookThroughSource.sol"; + +/// @dev A-09-v1 Equity owner recursive look-through (mock). Stands in for the +/// off-chain recursive ownership-graph walk (doc §5.2: depth/graph guard, +/// natural-person base case, entity-independent-qualification shortcut, +/// trust/family-company/formed-for-purpose branches) performed by a +/// Trusted Issuer. On-chain this element only records that walk's settled +/// outcome per subject and gates on it — it does not itself traverse +/// ownership graphs. +/// +/// NONE => dormant: no look-through was required/recorded for this +/// subject, so `check` PASSes. A-09 does not decide whether a look-through +/// was actually owed; A-08 (entity eligibility) is the consumer that +/// treats NONE as a missing prerequisite when the buyer's claimed category +/// requires one. Operators may reset a subject back to NONE (revocation). +/// +/// Reason codes collapse the doc §6.2 REVIEW_*/FAIL_* families into two +/// on-chain outcomes (fine-grained internal cause stays off-chain, per the +/// buyer-facing-vs-internal-record split in doc §6.4): +/// n | doc §6.2 code(s) collapsed in +/// 1 | REVIEW_OWNERSHIP_GRAPH_INCOMPLETE, REVIEW_LOOKTHROUGH_DEPTH_EXCEEDED, +/// | REVIEW_FAMILY_OWNERSHIP_ATTRIBUTION, REVIEW_AI_LOOKTHROUGH_PENDING, +/// | REVIEW_TRUST_QP_IV_INDEPENDENT, PARTIAL_REVIEW (graph incomplete => review/wait) +/// 2 | FAIL_LOOKTHROUGH_OWNER_NOT_QUALIFIED, FAIL_AI_OWNER_NOT_ACCREDITED, +/// | FAIL_FORMED_FOR_SPECIFIC_PURPOSE_NON_QP, FAIL_FAMILY_COMPOSITION_NOT_MET +/// | (non-qualifying owner found) +contract EquityOwnerLookThrough is BaseElement, Governed, ILookThroughSource { + bytes32 internal constant ELEMENT_ID = "A-09-v1"; + + /// @dev subject => settled recursive look-through outcome. Default (unset) + /// is NONE, matching ILookThroughSource's documented dormant default. + mapping(address => LookThroughStatus) public statusOf; + + event LookThroughStatusSet(address indexed subject, LookThroughStatus status); + + constructor() + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.INVESTOR_ATTRIBUTE, + version: "A-09-v1", + temporal: TemporalNature.ONE_TIME, + decidability: Decidability.ATTESTATION_BASED, + timing: ObligationTiming.EX_ANTE_VERIFY, + statefulness: Statefulness.STATELESS + })) + {} + + /// @notice Records the Trusted Issuer's settled look-through outcome for + /// `subject`. Setting NONE is a valid, explicit revocation. + function setLookThroughStatus(address subject, LookThroughStatus status) external onlyOperator { + statusOf[subject] = status; + emit LookThroughStatusSet(subject, status); + } + + /// @dev NONE/COMPLETED PASS; PENDING/FAILED FAIL with the collapsed codes + /// documented on the contract (doc §6.2). + function check(address user, address, address, uint256, bytes calldata) + external + view + override + returns (bool passed, bytes32 reasonCode) + { + LookThroughStatus status = statusOf[user]; + + if (status == LookThroughStatus.NONE || status == LookThroughStatus.COMPLETED) { + return (true, bytes32(0)); + } + if (status == LookThroughStatus.PENDING) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + // status == LookThroughStatus.FAILED + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } +} diff --git a/src/compliance/elements/HolderCount.sol b/src/compliance/elements/HolderCount.sol new file mode 100644 index 0000000..57395fc --- /dev/null +++ b/src/compliance/elements/HolderCount.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseStatefulElement} from "./BaseStatefulElement.sol"; +import {BaseElement} from "./BaseElement.sol"; +import {IComplianceElement} from "../../interfaces/compliance/IComplianceElement.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {Errors} from "../../libraries/Errors.sol"; + +/// @dev Element-local minimal views over A-04 (identity dedup) and A-03 (AI). Repo +/// precedent (IdentityUniqueness) prefers element-local interface declarations +/// to new interface files. +interface IIdentityView { + function identityOf(address wallet) external view returns (bytes32); +} + +interface IAiView { + function accredited(address user) external view returns (bool); +} + +/// @dev D-01-v1 Holder count (mock, STATEFUL). Counts held-of-record *persons* +/// (ONCHAINID, not wallets) of one equity-security class and gates trades that +/// would push the resulting count past an active exemption threshold. +/// +/// Legal frame: the numeric caps are NOT a §3(c)(7) headcount limit (§3(c)(7) +/// has none). TWELVE_G stands in for the Exchange Act §12(g)/Rule 12g-1 public- +/// company registration trigger (held of record < 2,000 and non-AI < 500), +/// THREE_C_1 for ICA §3(c)(1)'s <= 100 beneficial owners, FIVE_06_B for Rule +/// 506(b)(2)(i)'s <= 35 purchasers. Only TWELVE_G is live for the BUIDL-like +/// demo class; the other two are library completeness. +/// +/// 법문 트리거 vs 운영 게이트: the §12(g) registration duty is NOT real-time — +/// it attaches as of the last day of the first fiscal year in which the +/// asset+holder conditions are met (§12(g)(1); Rule 12g-1 reads "most recent +/// fiscal year"). This element's per-trade pre-trade gate is therefore a +/// conservative OPERATIONAL guard against fiscal-year-end registration risk, +/// not an implementation of "the 2,000th holder triggers registration on the +/// spot". Reaching a cap here means "operationally blocked", not "instantly +/// unlawful". +/// +/// Deployment scope: IStatefulElement.onTransfer(from,to,amount) carries no +/// asset param, so ONE instance tracks ONE share class — deploy one per asset. +/// This element counts and gates only; qualification (QP/AI) is A-13/A-03, +/// person<->wallet mapping is A-04. It never re-adjudicates those. +/// +/// reasonCode table (n -> doc §6.1 failure-code name): +/// 1 HOLDER_CAP_12G_TOTAL resulting held-of-record >= 2000 +/// 2 HOLDER_CAP_12G_NONAI resulting non-AI holders >= 500 +/// 3 HOLDER_CAP_3C1_100 resulting beneficial owners > 100 +/// 4 HOLDER_CAP_506B_35 resulting purchasers > 35 +/// +/// Auth: extends BaseStatefulElement only (as SurveillanceFlag does), NOT +/// Governed. BaseStatefulElement already declares `address public owner`, +/// whose auto-getter collides with Ownable.owner() and makes co-inheriting +/// Governed uncompilable without editing a base (forbidden). The onlyOperator +/// gate is therefore replicated locally against BaseStatefulElement.owner, +/// giving the same owner+operator write-gate Governed would. +contract HolderCount is BaseStatefulElement { + bytes32 internal constant ELEMENT_ID = "D-01-v1"; + + // Local operator gate (see Auth note): mirrors Governed's semantics but keys + // off BaseStatefulElement.owner instead of Ownable, avoiding the owner()/owner + // clash. owner (deployer) or any enabled operator may write state inputs. + mapping(address => bool) public isOperator; + + modifier onlyOperator() { + if (!isOperator[msg.sender] && msg.sender != owner) revert Errors.NotAuthorized(); + _; + } + + /// @dev Active cap regime for this class (§5.3 inequality discipline lives in + /// _capBreachCode). NONE => element dormant (always PASS). + enum CapMode { + NONE, + TWELVE_G, + THREE_C_1, + FIVE_06_B + } + + // Statutory thresholds (doc §5.3). The comparison operator is part of each + // constant's meaning: §12(g)/12g-1 keep the count strictly BELOW (">=" fails), + // §3(c)(1)/506(b) allow reaching the cap (">" fails). + uint256 internal constant CAP_12G_TOTAL = 2000; // >= 2000 fails (< 2000 safe) + uint256 internal constant CAP_12G_NONAI = 500; // >= 500 fails (< 500 safe) + uint256 internal constant CAP_3C1 = 100; // > 100 fails (<= 100 safe) + uint256 internal constant CAP_506B = 35; // > 35 fails (<= 35 safe) + + IIdentityView public immutable identity; // A-04 dedup source + IAiView public immutable ai; // A-03 accreditation source + + CapMode public capMode; + /// @dev §12(g) $10M total-assets gate, attested off-chain (B-01/manifest). Only + /// consulted in TWELVE_G mode: if false, Rule 12g-1(a) exemption => PASS. + bool public assetGateMet; + + // --- accumulated state (§5.3) --- + uint256 public holderCount; // held-of-record persons + uint256 public nonAiCount; // non-accredited holders (12g-1(b)(1)) + /// @dev per-person summed balance; a person is a holder iff this is > 0. + mapping(bytes32 => uint256) public balanceOfIdentity; + /// @dev AI-ness SNAPSHOTTED at entry. Invariant: the exit decrement of + /// nonAiCount uses THIS snapshot, never a live A-03 read, so a later + /// AccreditedInvestor flip cannot desync the counter. + mapping(bytes32 => bool) public countedNonAi; + + error ZeroDependency(); + + event OperatorSet(address indexed operator, bool enabled); + event CapModeSet(CapMode capMode); + event AssetGateSet(bool met); + /// @dev Emitted by onTransfer on each holder entry (+1) / exit (-1). + event HolderCountChanged( + bytes32 indexed identityId, address indexed wallet, bool entered, uint256 holderCount, uint256 nonAiCount + ); + + constructor(CapMode capMode_, IIdentityView identity_, IAiView ai_) + BaseStatefulElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.SYSTEM_STATE, + version: "D-01-v1", + temporal: TemporalNature.CUMULATIVE, + decidability: Decidability.DETERMINISTIC, + timing: ObligationTiming.AT_TRADE_GATE, + statefulness: Statefulness.STATEFUL + })) + { + if (address(identity_) == address(0) || address(ai_) == address(0)) revert ZeroDependency(); + identity = identity_; + ai = ai_; + capMode = capMode_; + } + + /// @dev owner-gated (matches BaseStatefulElement.setEngine's owner gate). + function setOperator(address op, bool enabled) external { + if (msg.sender != owner) revert Errors.NotAuthorized(); + isOperator[op] = enabled; + emit OperatorSet(op, enabled); + } + + function setCapMode(CapMode capMode_) external onlyOperator { + capMode = capMode_; + emit CapModeSet(capMode_); + } + + function setAssetGateMet(bool met) external onlyOperator { + assetGateMet = met; + emit AssetGateSet(met); + } + + /// @dev Resolve a wallet to its counting unit. Unbound (identityOf == 0) => the + /// wallet is its own identity (Rule 12g5-1(a): a record holder counts as 1). + function _identity(address wallet) internal view returns (bytes32) { + bytes32 id = identity.identityOf(wallet); + return id == bytes32(0) ? bytes32(uint256(uint160(wallet))) : id; + } + + /// @dev Prospective-entrant boundary test (doc §5.3). Returns the failing code + /// (0 = PASS) for adding `buyer` as a NEW holder given current counters. + /// Cheapest-first: NONE, then the TWELVE_G asset-gate bypass, then compare. + function _capBreachCode(address buyer) internal view returns (uint32) { + CapMode mode = capMode; + if (mode == CapMode.NONE) return 0; + + if (mode == CapMode.TWELVE_G) { + if (!assetGateMet) return 0; // Rule 12g-1(a) exemption + if (holderCount + 1 >= CAP_12G_TOTAL) return 1; + // Non-AI budget is only consumed by non-accredited entrants. + if (!ai.accredited(buyer) && nonAiCount + 1 >= CAP_12G_NONAI) return 2; + return 0; + } + if (mode == CapMode.THREE_C_1) { + return holderCount + 1 > CAP_3C1 ? 3 : 0; + } + // FIVE_06_B + return holderCount + 1 > CAP_506B ? 4 : 0; + } + + /// @dev Pre-trade gate. `user` is the buyer. view: no events here (§ check is + /// view); state-change events live in onTransfer. + function check(address user, address, address, uint256 amount, bytes calldata) + external + view + override(BaseElement, IComplianceElement) + returns (bool passed, bytes32 reasonCode) + { + if (amount == 0) return (true, bytes32(0)); // no count effect + if (balanceOfIdentity[_identity(user)] > 0) return (true, bytes32(0)); // P1: already a holder + uint32 code = _capBreachCode(user); + if (code == 0) return (true, bytes32(0)); + return (false, ReasonCodes.encode(0, ELEMENT_ID, code)); + } + + /// @dev Post-trade commit (onlyEngine via BaseStatefulElement). Entry +1 / exit + /// -1 with per-identity balance bookkeeping. from == 0 is mint (no exit + /// side), to == 0 is burn (no entry side). Re-validates the cap at commit + /// time (gate-to-commit race defense, doc §8.3) and reverts on breach. + function onTransfer(address from, address to, uint256 amount) external override onlyEngine { + if (amount == 0) return; + + // Snapshot pre-mutation facts (entry detection + exit-transition guard). + bytes32 idTo; + bool newHolder; + if (to != address(0)) { + idTo = _identity(to); + newHolder = balanceOfIdentity[idTo] == 0; + } + bytes32 idFrom; + uint256 fromBalBefore; + if (from != address(0)) { + idFrom = _identity(from); + fromBalBefore = balanceOfIdentity[idFrom]; + } + + // Entry: re-validate the cap, then +1 and snapshot AI-ness. + if (to != address(0) && newHolder) { + uint32 code = _capBreachCode(to); + if (code != 0) revert Errors.ComplianceRejected(ReasonCodes.encode(0, ELEMENT_ID, code)); + holderCount += 1; + bool nonAi = !ai.accredited(to); + countedNonAi[idTo] = nonAi; + if (nonAi) nonAiCount += 1; + emit HolderCountChanged(idTo, to, true, holderCount, nonAiCount); + } + + // Balance bookkeeping: mirror the amount the engine reports, floor the + // debit at the holder's balance so a stale/over-reported amount can never + // underflow. from and to may share an identity (net-zero) — handled by + // reading fromBalBefore before the credit. + if (from != address(0)) { + uint256 dec = amount > fromBalBefore ? fromBalBefore : amount; + balanceOfIdentity[idFrom] = fromBalBefore - dec; + } + if (to != address(0)) { + balanceOfIdentity[idTo] += amount; + } + + // Exit: from's identity balance transitioned to 0. Decrement nonAiCount by + // the ENTRY snapshot, not a live read, then clear it. + if (from != address(0) && fromBalBefore > 0 && balanceOfIdentity[idFrom] == 0) { + holderCount -= 1; + if (countedNonAi[idFrom]) nonAiCount -= 1; + delete countedNonAi[idFrom]; + emit HolderCountChanged(idFrom, from, false, holderCount, nonAiCount); + } + } +} diff --git a/src/compliance/elements/TransferRestrictionMetadata.sol b/src/compliance/elements/TransferRestrictionMetadata.sol new file mode 100644 index 0000000..eeec548 --- /dev/null +++ b/src/compliance/elements/TransferRestrictionMetadata.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import {Governed} from "../../auth/Governed.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; + +/// @dev B-03-v1 Transfer restriction metadata declaration (mock). Stands in for +/// the digital equivalent of a Rule 502(d)(3) restrictive legend: it does +/// not compute whether a given resale is permitted (that is C-00/C-01's +/// job) — it only checks that the asset's per-class restriction +/// declaration is present, internally consistent with the issuance +/// framework, and complete enough for those downstream elements to read. +/// ASSET-side check: it inspects `asset`, not `user`/`counterparty` +/// (contrast with the investor-attribute elements; same shape as +/// AssetClassification). +/// +/// `check` is inherited as `view` from `BaseElement`/`IComplianceElement` +/// and that file may not be edited, so this element cannot emit events +/// from inside `check` (Solidity forbids emitting logs from a `view` +/// override). The doc's §5.2 pseudocode calls for (a) a PASS event +/// carrying a snapshot hash of the declaration and (b) a non-blocking +/// `ReviewQueued` event when a NON_REPORTING asset overclaims +/// `currentInfoRequired` (§5.2 step ④, "hardening overclaim = ops +/// hygiene"). Both are dropped from the runtime check as a consequence of +/// the interface constraint — the underlying PASS/FAIL semantics (the +/// overclaim never blocks the trade) are preserved exactly, only the +/// diagnostic event emission is omitted. Declared as a deviation from the +/// doc's pseudocode. +contract TransferRestrictionMetadata is BaseElement, Governed { + bytes32 internal constant ELEMENT_ID = "B-03-v1"; + + // Reason code map — n in ReasonCodes.encode(0, ELEMENT_ID, n) --> doc §6.2 name: + // 1 = RESTRICTION_DECL_MISSING (gate ① — declaration block absent; missing != false) + // 2 = RESTRICTION_STATUS_CONFLICT(gate ② — framework requires restricted, flag says false) + // 3 = RESTRICTION_TAGS_INCOMPLETE(gate ③ — a required tag is empty/unset/mismatched) + // 4 = RESTRICTION_TAG_INVALID (gate ③ — a tag holds a value outside its legal set) + // 5 = RESTRICTION_TAG_CONFLICT (gate ④ — tags contradict each other in the relaxing direction) + // 6 = UNRESTRICT_BASIS_MISSING (gate ⑤ — flag=false claimed without an approved exit basis) + + /// @dev Card-external legend value: distinguishes non-reporting (12-month + /// Rule 144(d)(1)(ii) floor) from reporting-for-90-days (6-month + /// 144(d)(1)(i) floor). UNSET (0) is the fail-closed default for an + /// undeclared/incomplete card and is never a valid final value. + enum ReportingStatus { + UNSET, + NON_REPORTING, + REPORTING_90D + } + + /// @dev Per-asset (per-class) restriction declaration — the digital legend. + /// `declared` is a distinct tri-state presence flag: a card that never + /// had a declaration block written (`declared == false`) is a + /// different failure (DECL_MISSING) from one that explicitly declares + /// `restrictedFlag == false` (a STATUS_CONFLICT candidate) — collapsing + /// the two by treating "missing" as "false" is the doc's flagged + /// regression (§5.3 row ①). + struct RestrictionDecl { + bool declared; + bool restrictedFlag; + bytes32 issuanceFramework; + uint32 enabledResalePaths; // bitmask; 0 = no path declared + uint8 holdingPeriodMonths; // legal set {6, 12} — SET MEMBERSHIP, not a magnitude comparison + ReportingStatus reportingStatus; + bool currentInfoRequired; + bytes32 legendRef; // 0 = missing citation anchor + bytes32 classRef; // must equal legalClassId (§4(a)(7)(d)(3)(C) — declaration binds to a specific class) + bytes32 legalClassId; + bytes32 unrestrictBasisRef; // 0 = no exit basis registered + } + + /// @notice asset => restriction declaration (default: all-zero => undeclared). + mapping(address => RestrictionDecl) public declarationOf; + + /// @notice Governance constant, kept OUTSIDE the card (self-reference + /// guard, doc §5.2/§4.2): issuance framework => whether Rule + /// 144(a)(3)-derived status requires `restrictedFlag == true`. + mapping(bytes32 => bool) public requiresRestricted; + + /// @notice Governance constant: bitmask of resale-path enum values the + /// system currently recognizes as valid (`enabledResalePaths` must be + /// a non-empty subset of this mask). + uint32 public validPathsMask; + + /// @notice Governance constant: basis references (opinion letter + TA + /// confirmation, registered off-chain and referenced by hash) approved + /// to support an `unrestrictBasisRef` exit claim. + mapping(bytes32 => bool) public approvedUnrestrictBasis; + + event DeclarationSet(address indexed asset, bytes32 declarationHash); + event RequiresRestrictedSet(bytes32 indexed issuanceFramework, bool required); + event ValidPathsMaskSet(uint32 mask); + event ApprovedUnrestrictBasisSet(bytes32 indexed basisRef, bool approved); + + constructor() + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.ASSET_ATTRIBUTE, + version: "B-03-v1", + temporal: TemporalNature.ONE_TIME, + decidability: Decidability.DETERMINISTIC, + timing: ObligationTiming.AT_TRADE_GATE, + statefulness: Statefulness.STATELESS + })) + {} + + /// @notice Writes (or clears, by passing a zeroed struct) the restriction + /// declaration for `asset` as one atomic block. + function setDeclaration(address asset, RestrictionDecl calldata decl) external onlyOperator { + declarationOf[asset] = decl; + emit DeclarationSet(asset, keccak256(abi.encode(decl))); + } + + /// @notice Sets whether `issuanceFramework` requires `restrictedFlag == true` + /// (Rule 144(a)(3) status derivation, held off the card). + function setRequiresRestricted(bytes32 issuanceFramework, bool required) external onlyOperator { + requiresRestricted[issuanceFramework] = required; + emit RequiresRestrictedSet(issuanceFramework, required); + } + + /// @notice Sets the system-recognized valid resale-path bitmask. + function setValidPathsMask(uint32 mask) external onlyOperator { + validPathsMask = mask; + emit ValidPathsMaskSet(mask); + } + + /// @notice Approves (or revokes approval of) an unrestrict exit basis + /// reference for use in gate ⑤. + function setApprovedUnrestrictBasis(bytes32 basisRef, bool approved) external onlyOperator { + approvedUnrestrictBasis[basisRef] = approved; + emit ApprovedUnrestrictBasisSet(basisRef, approved); + } + + /// @dev Evaluates the doc §5.2 gates ①–⑤ in order against `asset`'s + /// declaration, returning the FIRST failing code. `user`, `counterparty`, + /// `amount` and `context` are ignored — asset-side check (AssetClassification + /// precedent). + function check(address, address, address asset, uint256, bytes calldata) + external + view + override + returns (bool passed, bytes32 reasonCode) + { + RestrictionDecl memory d = declarationOf[asset]; + + // ① existence — a legend slot that was never written is a distinct + // failure from one explicitly declaring restrictedFlag = false. + if (!d.declared) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + + // ② status conflict — Rule 144(a)(3) derivation vs. declared flag. + // Direction rule: only the RELAXING mismatch (required=true, + // flag=false) is blocked. required=false with flag=true is a + // HARDENING (conservative) declaration and passes — there is no + // symmetric check in the other direction. + if (requiresRestricted[d.issuanceFramework] && !d.restrictedFlag) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } + + if (d.restrictedFlag) { + // ③ required-tag completeness and validity (Rule 502(d)(3) elements). + if (d.enabledResalePaths == 0) { + // Non-zero check is separate from the subset check below: the + // empty set is (vacuously) a subset of validPathsMask, so a + // subset-only test would let an empty declaration through — + // the doc's flagged regression (§5.3 row ③a). + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + if (d.enabledResalePaths & ~validPathsMask != 0) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + if (d.holdingPeriodMonths != 6 && d.holdingPeriodMonths != 12) { + // Rule 144(d)(1) gives exactly two lawful values — this is + // SET MEMBERSHIP, never "holdingPeriodMonths >= 6" or any + // other magnitude comparison (doc §5.3 row ③b). + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + if (d.reportingStatus == ReportingStatus.UNSET) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + if (d.classRef != d.legalClassId) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + if (d.legendRef == bytes32(0)) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + + // ④ internal tag consistency — relaxing-direction contradictions + // only (Rule 144(d)(1)/(b)(1)). + if (d.reportingStatus == ReportingStatus.NON_REPORTING && d.holdingPeriodMonths == 6) { + // Non-reporting issuer's floor is 12 months; a 6-month tag + // relaxes that floor and would misdirect C-01. + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + // NON_REPORTING && currentInfoRequired == true is a HARDENING + // overclaim (the asset asserts a stricter info requirement than + // its non-reporting status demands) — doc §5.2 sends this to a + // non-blocking REVIEW queue, not a FAIL. As noted in the + // contract-level @dev comment, `check` cannot emit the doc's + // `ReviewQueued` event because it must remain `view`; the + // non-blocking behavior itself (falling through without + // reverting/failing) is preserved. + if (d.reportingStatus == ReportingStatus.REPORTING_90D && !d.currentInfoRequired) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + } else { + // ⑤ unrestrict exit basis — reached only when gate ② allows + // restrictedFlag == false. Requires both presence AND approved- + // chain membership; checking presence alone would let an + // unapproved basis reference flag the asset unrestricted + // (doc §5.3 row ⑤). + if (d.unrestrictBasisRef == bytes32(0) || !approvedUnrestrictBasis[d.unrestrictBasisRef]) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 6)); + } + } + + return (true, bytes32(0)); + } +} diff --git a/src/interfaces/compliance/ILookThroughSource.sol b/src/interfaces/compliance/ILookThroughSource.sol new file mode 100644 index 0000000..06d0ab4 --- /dev/null +++ b/src/interfaces/compliance/ILookThroughSource.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +// Off-chain recursive equity-owner look-through outcome (doc ELE.A-09 §5), +// consumed on-chain as a settled status. NONE = no look-through recorded for +// the subject — the consumer decides whether that is dormant (A-09 standalone) +// or a missing prerequisite (A-08 when a look-through-typed category is claimed). +enum LookThroughStatus { + NONE, + PENDING, + COMPLETED, + FAILED +} + +/// @dev Minimal read surface the entity-eligibility element (A-08) consumes +/// from the equity-owner look-through element (A-09). +interface ILookThroughSource { + function statusOf(address subject) external view returns (LookThroughStatus); +} diff --git a/test/unit/compliance/elements/ClaimFreshness.t.sol b/test/unit/compliance/elements/ClaimFreshness.t.sol new file mode 100644 index 0000000..3d4d4cf --- /dev/null +++ b/test/unit/compliance/elements/ClaimFreshness.t.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {ClaimFreshness} from "../../../../src/compliance/elements/ClaimFreshness.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import { + ElementMetadata, + ElementCategory, + Decidability, + ObligationTiming, + Statefulness, + TemporalNature +} from "../../../../src/types/ComplianceTypes.sol"; + +contract ClaimFreshnessTest is Test { + // Re-declared to match ClaimFreshness.ClaimSet for vm.expectEmit (Solidity + // 0.8.17 cannot reference a non-library contract's event by qualified name + // in an `emit` statement; that requires >=0.8.22). + event ClaimSet( + address indexed user, ClaimFreshness.FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry + ); + + bytes32 internal constant ELEMENT_ID = "A-11-v1"; + + address internal user = address(0xA11CE); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + // Arbitrary fixed epoch anchor so every test's timeline is deterministic + // regardless of the harness's default block.timestamp. + uint64 internal constant START = 1_700_000_000; + + ClaimFreshness internal element; + + function setUp() public { + element = new ClaimFreshness(); + vm.warp(START); + } + + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), ELEMENT_ID, n)); + } + + // --------------------------------------------------------------- + // Metadata + auth + // --------------------------------------------------------------- + + function test_metadata_fields() public { + ElementMetadata memory m = element.elementMetadata(); + assertEq(m.elementId, ELEMENT_ID); + assertEq(uint256(m.category), uint256(ElementCategory.INVESTOR_ATTRIBUTE)); + assertEq(uint256(m.decidability), uint256(Decidability.DETERMINISTIC)); + assertEq(uint256(m.timing), uint256(ObligationTiming.AT_TRADE_GATE)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + assertEq(uint256(m.temporal), uint256(TemporalNature.PERIODIC)); + } + + function test_setClaim_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + } + + function test_setClaim_ownerCanSet_andEmitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit ClaimSet(user, ClaimFreshness.FreshClaimType.AI, START, 0); + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + + (ClaimFreshness.FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry) = element.claimOf(user); + assertEq(uint256(claimType), uint256(ClaimFreshness.FreshClaimType.AI)); + assertEq(verifiedAt, START); + assertEq(issuerExpiry, 0); + } + + function test_setClaim_operatorCanSet() public { + element.setOperator(operator, true); + + vm.prank(operator); + element.setClaim(user, ClaimFreshness.FreshClaimType.QP, START, 0); + + (ClaimFreshness.FreshClaimType claimType,,) = element.claimOf(user); + assertEq(uint256(claimType), uint256(ClaimFreshness.FreshClaimType.QP)); + } + + // --------------------------------------------------------------- + // doc §7 Test 1 — Pass-AI + // --------------------------------------------------------------- + + function test_check_passesAi_wellWithinFiveYearCap() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + vm.warp(START + 2 * 365 days); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // doc §7 Test 2 — Pass-QP + function test_check_passesQp_wellWithinOneYearCap() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.QP, START, 0); + vm.warp(START + 300 days); // ~10 months + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // doc §7 Test 3 — Fail-AI-stale + function test_check_failsAi_afterFiveYearCap() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + vm.warp(START + 6 * 365 days); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + // doc §7 Test 4 — Fail-QP-stale (policy cap, not statutory) + function test_check_failsQp_afterOneYearPolicyCap() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.QP, START, 0); + vm.warp(START + 14 * 30 days); // ~14 months + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(3)); + } + + // --------------------------------------------------------------- + // doc §7 Test 5 — boundary: exactly-at-cap PASSes, cap+1s FAILs. + // Pinned for both claim types since the comparison operator (strict `>`) + // is the heart of this element's spec. + // --------------------------------------------------------------- + + function test_check_boundary_ai_exactlyAtCapPasses() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + vm.warp(START + element.CAP_AI()); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_boundary_ai_oneSecondPastCapFails() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + vm.warp(START + element.CAP_AI() + 1); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + function test_check_boundary_qp_exactlyAtCapPasses() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.QP, START, 0); + vm.warp(START + element.CAP_QP()); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_boundary_qp_oneSecondPastCapFails() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.QP, START, 0); + vm.warp(START + element.CAP_QP() + 1); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(3)); + } + + // --------------------------------------------------------------- + // doc §7 Test 6 — issuer-set expiry interplay. + // --------------------------------------------------------------- + + // Shorter issuer expiry wins over the (longer) regulatory/policy cap. + function test_check_issuerExpiryShorterThanCap_winsOverRegulatoryCap() public { + uint64 issuerExpiry = START + 365 days; // 1y, shorter than the 5y AI cap + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, issuerExpiry); + vm.warp(START + 13 * 30 days); // ~13 months: past issuer expiry, well within 5y cap + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(4)); + } + + function test_check_issuerExpiry_exactlyAtBoundaryPasses() public { + uint64 issuerExpiry = START + 365 days; + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, issuerExpiry); + vm.warp(issuerExpiry); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_issuerExpiry_oneSecondPastFails() public { + uint64 issuerExpiry = START + 365 days; + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, issuerExpiry); + vm.warp(issuerExpiry + 1); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(4)); + } + + // Longer issuer expiry than the regulatory cap is ignored entirely — the + // stale code fires at the cap boundary, NOT the issuer-expired code. + function test_check_issuerExpiryLongerThanCap_isIgnored() public { + uint64 issuerExpiry = START + 6 * 365 days; // longer than the 5y AI cap + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, issuerExpiry); + vm.warp(START + 6 * 365 days); // past the 5y regulatory cap, still before issuerExpiry + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); // STALE_AI, not CLAIM_EXPIRED + } + + // --------------------------------------------------------------- + // doc §7 Test 7 — missing verifiedAt + // --------------------------------------------------------------- + + function test_check_failsWhenVerifiedAtMissing() public { + // No setClaim call: default FreshnessClaim{UNKNOWN, 0, 0}. + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(1)); + } + + // Unknown claim type — fail-closed, must never default to the laxer AI cap. + function test_check_failsWhenClaimTypeUnknown() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.UNKNOWN, START, 0); + vm.warp(START + 1); + + (bool passed, bytes32 reasonCode) = element.check(user, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(5)); + } + + // --------------------------------------------------------------- + // doc §7 Test 8 — A-11 is a pure time gate: identical result regardless + // of counterparty/asset/amount identity. + // --------------------------------------------------------------- + + function test_check_ignoresCounterpartyAssetAmount() public { + element.setClaim(user, ClaimFreshness.FreshClaimType.AI, START, 0); + vm.warp(START + 2 * 365 days); + + (bool passed1,) = element.check(user, address(0), address(0), 0, ""); + (bool passed2,) = element.check(user, address(0xB0B), address(0xDEAD), 12345, ""); + assertTrue(passed1); + assertTrue(passed2); + assertEq(passed1, passed2); + } +} diff --git a/test/unit/compliance/elements/EngineSelection.t.sol b/test/unit/compliance/elements/EngineSelection.t.sol new file mode 100644 index 0000000..393ad76 --- /dev/null +++ b/test/unit/compliance/elements/EngineSelection.t.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {EngineSelection} from "../../../../src/compliance/elements/EngineSelection.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness, + ComplianceContext, + VenueType +} from "../../../../src/types/ComplianceTypes.sol"; + +contract EngineSelectionTest is Test { + // Re-declared to match EngineSelection's events for vm.expectEmit (Solidity + // 0.8.17 cannot reference a non-library contract's event by qualified name in + // an `emit` statement; that requires >=0.8.22). + event SupportedEnginesSet(address indexed asset, uint8 engines); + event MarketMakerClaimSet(address indexed buyer, bool hasClaim); + event NoGsEngineSetUpdated(uint8 engineSet); + event AffiliateEngineSetUpdated(uint8 engineSet); + + // Manifest bit convention: bit i == VenueType(i) (AMM=0, ORDER_BOOK=1, RFQ=2). + uint8 internal constant AMM_BIT = 0x01; + uint8 internal constant OB_BIT = 0x02; + uint8 internal constant RFQ_BIT = 0x04; + uint8 internal constant CARD_RFQ_OB = OB_BIT | RFQ_BIT; // BUIDL-like: {RFQ, OB} + uint8 internal constant CARD_ALL = AMM_BIT | OB_BIT | RFQ_BIT; + + address internal asset = address(0xBEEF); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + address internal buyer = address(0xB0B); // stands in for the counterparty + address internal seller = address(0xA11CE); + + EngineSelection internal element; + + function setUp() public { + element = new EngineSelection(); + element.setSupportedEngines(asset, CARD_RFQ_OB); + } + + // ── helpers ──────────────────────────────────────────────────────────── + function _reason(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("B-04-v1"), uint32(n))); + } + + function _ctx(VenueType v, address buyer_, bool affiliate) internal pure returns (bytes memory) { + ComplianceContext memory c; + c.buyer = buyer_; + c.seller = address(0xA11CE); + c.venueType = v; + c.sellerIsAffiliate = affiliate; + return abi.encode(c); + } + + function _check(address asset_, VenueType v, address buyer_, bool affiliate) + internal + view + returns (bool passed, bytes32 reasonCode) + { + return element.check(seller, buyer_, asset_, 0, _ctx(v, buyer_, affiliate)); + } + + // ── metadata / construction ───────────────────────────────────────────── + function test_metadata_fields() public { + ElementMetadata memory m = element.elementMetadata(); + assertEq(m.elementId, bytes32("B-04-v1")); + assertEq(uint256(m.category), uint256(ElementCategory.RESALE_TRANSACTION)); + assertEq(uint256(m.temporal), uint256(TemporalNature.REALTIME)); + assertEq(uint256(m.decidability), uint256(Decidability.DETERMINISTIC)); + assertEq(uint256(m.timing), uint256(ObligationTiming.AT_TRADE_GATE)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + } + + function test_constructor_initsGovernanceSetsToRfqOnly() public { + assertEq(element.noGsEngineSet(), RFQ_BIT); + assertEq(element.affiliateEngineSet(), RFQ_BIT); + } + + // ── operator gating + setter events ────────────────────────────────────── + function test_setSupportedEngines_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setSupportedEngines(asset, CARD_ALL); + } + + function test_setMarketMakerClaim_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setMarketMakerClaim(buyer, true); + } + + function test_setNoGsEngineSet_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setNoGsEngineSet(CARD_ALL); + } + + function test_setSupportedEngines_emitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit SupportedEnginesSet(asset, CARD_ALL); + element.setSupportedEngines(asset, CARD_ALL); + assertEq(element.supportedEnginesOf(asset), CARD_ALL); + } + + function test_setMarketMakerClaim_emitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit MarketMakerClaimSet(buyer, true); + element.setMarketMakerClaim(buyer, true); + assertTrue(element.mmClaimOf(buyer)); + } + + function test_setGovernanceSets_emitEvents() public { + vm.expectEmit(false, false, false, true); + emit NoGsEngineSetUpdated(CARD_ALL); + element.setNoGsEngineSet(CARD_ALL); + assertEq(element.noGsEngineSet(), CARD_ALL); + + vm.expectEmit(false, false, false, true); + emit AffiliateEngineSetUpdated(CARD_ALL); + element.setAffiliateEngineSet(CARD_ALL); + assertEq(element.affiliateEngineSet(), CARD_ALL); + } + + function test_operatorCanSet() public { + element.setOperator(operator, true); + vm.prank(operator); + element.setSupportedEngines(asset, CARD_ALL); + assertEq(element.supportedEnginesOf(asset), CARD_ALL); + } + + // ── T1: non-affiliate order-book pass ──────────────────────────────────── + function test_T1_nonAffiliateOrderBook_pass() public { + (bool passed, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, false); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + // ── T2: undeclared engine => code 4 ────────────────────────────────────── + function test_T2_undeclaredEngine_fails4() public { + // AMM is not in the {RFQ, OB} card; G③ blocks before any overlay. + (bool passed, bytes32 rc) = _check(asset, VenueType.AMM, buyer, false); + assertFalse(passed); + assertEq(rc, _reason(4)); + } + + // ── T3: affiliate + order-book => code 6 (even though OB is declared) ───── + function test_T3_affiliateOrderBook_fails6() public { + // 김 부장 scenario: OB is in the declared set (G③ passes) but not in the + // affiliate manner-of-sale set, so G⑤a closes it for this seller. + (bool passed, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, true); + assertFalse(passed); + assertEq(rc, _reason(6)); + } + + // ── T4: affiliate + RFQ + buyer MM claim => PASS (김 부장) ──────────────── + function test_T4_affiliateRfqWithMmClaim_pass() public { + element.setMarketMakerClaim(buyer, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, true); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + // ── T5: affiliate + RFQ, no MM claim => code 7 ─────────────────────────── + function test_T5_affiliateRfqNoMmClaim_fails7() public { + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, true); + assertFalse(passed); + assertEq(rc, _reason(7)); + } + + // ── DEBT carve-out: affiliate + order-book on debt security => PASS ────── + function test_debtSecurity_affiliateOrderBook_pass() public { + element.setDebtSecurity(asset, true); + // Rule 144(f)(3)(ii): (f) does not apply to debt securities, so the + // affiliate overlay is skipped entirely and OB (declared) passes. + (bool passed, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, true); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + // ── T6: path overlay pair + intersection ───────────────────────────────── + function test_T6a_sec4a7OrderBook_fails5() public { + element.setSec4a7Path(asset, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, false); + assertFalse(passed); + assertEq(rc, _reason(5)); + } + + function test_T6b_rule144OrderBook_pass() public { + // Asymmetry regression: the logic that blocks the SEC4A7+OB case in T6a + // must NOT block a Rule 144 (sec4a7=false) OB sale — no manner axis there. + (bool passed, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, false); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_T6c_sec4a7Amm_fails5() public { + // AMM must be declared to reach G④; use the all-engines card. + element.setSupportedEngines(asset, CARD_ALL); + element.setSec4a7Path(asset, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.AMM, buyer, false); + assertFalse(passed); + assertEq(rc, _reason(5)); + } + + function test_T6d_sec4a7Rfq_pass() public { + element.setSec4a7Path(asset, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, false); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + // Intersection: both G④ and G⑤ active. Engine must be in BOTH sets. This is + // pinned even though noGsEngineSet == affiliateEngineSet == {RFQ} today, so a + // future union-implementation bug surfaces when the sets diverge (doc §5.3). + function test_T6_intersection_affiliateSec4a7RfqWithMmClaim_pass() public { + element.setSec4a7Path(asset, true); + element.setMarketMakerClaim(buyer, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, true); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_T6_intersection_minusMmClaim_fails7() public { + element.setSec4a7Path(asset, true); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, true); + assertFalse(passed); + assertEq(rc, _reason(7)); + } + + // ── T7: card regression (missing / unknown bit) ────────────────────────── + function test_T7a_missingDeclaration_fails1() public { + element.setSupportedEngines(asset, 0); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, false); + assertFalse(passed); + assertEq(rc, _reason(1)); + } + + function test_T7b_unknownBit_fails2() public { + // bit3 (0x08) is outside VALID_ENGINES; must NOT be silently AND-masked. + element.setSupportedEngines(asset, 0x08 | RFQ_BIT); + (bool passed, bytes32 rc) = _check(asset, VenueType.RFQ, buyer, false); + assertFalse(passed); + assertEq(rc, _reason(2)); + } + + // ── empty context => code 3 (fail-closed safety pin) ───────────────────── + function test_emptyContext_fails3() public { + (bool passed, bytes32 rc) = element.check(seller, buyer, asset, 0, ""); + assertFalse(passed); + assertEq(rc, _reason(3)); + } + + // ── T8: 90-day affiliate tail — flag true blocks, flag false passes ────── + function test_T8_affiliateTail_blocksThenPasses() public { + // A-06 keeps sellerIsAffiliate = true through the 90-day tail even after a + // director resigns; the OB sale must still fail while the flag is set. + (bool blocked, bytes32 rc) = _check(asset, VenueType.ORDER_BOOK, buyer, true); + assertFalse(blocked); + assertEq(rc, _reason(6)); + + // Once the tail lapses (flag cleared upstream), the same OB sale passes. + (bool passed, bytes32 rc2) = _check(asset, VenueType.ORDER_BOOK, buyer, false); + assertTrue(passed); + assertEq(rc2, bytes32(0)); + } + + // ── validateEngineDeclaration (listing-time V1/V2) ─────────────────────── + function test_validateEngineDeclaration_ok() public { + (bool ok, bytes32 rc) = element.validateEngineDeclaration(asset); + assertTrue(ok); + assertEq(rc, bytes32(0)); + } + + function test_validateEngineDeclaration_missing_code1() public { + (bool ok, bytes32 rc) = element.validateEngineDeclaration(address(0xDEAD)); + assertFalse(ok); + assertEq(rc, _reason(1)); + } + + function test_validateEngineDeclaration_invalid_code2() public { + element.setSupportedEngines(asset, 0x08); + (bool ok, bytes32 rc) = element.validateEngineDeclaration(asset); + assertFalse(ok); + assertEq(rc, _reason(2)); + } +} diff --git a/test/unit/compliance/elements/EntityEligibility.t.sol b/test/unit/compliance/elements/EntityEligibility.t.sol new file mode 100644 index 0000000..46301b1 --- /dev/null +++ b/test/unit/compliance/elements/EntityEligibility.t.sol @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {EntityEligibility} from "../../../../src/compliance/elements/EntityEligibility.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import {ILookThroughSource, LookThroughStatus} from "../../../../src/interfaces/compliance/ILookThroughSource.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../../../src/types/ComplianceTypes.sol"; + +/// @dev Minimal in-test ILookThroughSource (A-09 not yet in this worktree). +contract MockLookThroughSource is ILookThroughSource { + mapping(address => LookThroughStatus) internal _status; + + function setStatus(address subject, LookThroughStatus status) external { + _status[subject] = status; + } + + function statusOf(address subject) external view override returns (LookThroughStatus) { + return _status[subject]; + } +} + +contract EntityEligibilityTest is Test { + // Re-declared for vm.expectEmit; enum params canonicalize to uint8 in the + // event signature so uint8 here matches the contract's enum-typed params + // (Solidity 0.8.17 cannot `emit` a contract's event by qualified name). + event EntityClaimSet( + address indexed user, + bool isEntity, + uint8 aiBasis, + uint8 qpBasis, + uint256 investmentsUsd, + bool formedForPurpose, + bool qibConfirmed, + bool directReqsMet + ); + event RequiredTracksSet(address indexed asset, uint8 tracks); + event EntityAndGateDiagnostic(address indexed user, address indexed asset, uint32 aiCode, uint32 qpCode); + + uint8 internal constant TRACK_AI = 1; + uint8 internal constant TRACK_QP = 2; + uint8 internal constant TRACK_BOTH = 3; + + address internal buyer = address(0xB0B); + address internal asset = address(0xA55E7); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + MockLookThroughSource internal look; + EntityEligibility internal element; + + function setUp() public { + look = new MockLookThroughSource(); + element = new EntityEligibility(look); + } + + // --- helpers --------------------------------------------------------- + + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("A-08-v1"), uint32(n))); + } + + function _claim( + EntityEligibility.AiBasis ai, + EntityEligibility.QpBasis qp, + uint256 inv, + bool ffp, + bool qib, + bool direct + ) internal pure returns (EntityEligibility.EntityClaim memory) { + return EntityEligibility.EntityClaim({ + isEntity: true, + aiBasis: ai, + qpBasis: qp, + investmentsUsd: inv, + formedForPurpose: ffp, + qibConfirmed: qib, + directReqsMet: direct + }); + } + + function _set(uint8 tracks, EntityEligibility.EntityClaim memory c) internal { + element.setRequiredTracks(asset, tracks); + element.setEntityClaim(buyer, c); + } + + function _check() internal view returns (bool passed, bytes32 reasonCode) { + return element.check(buyer, address(0), asset, 0, ""); + } + + function _assertFail(uint32 n) internal { + (bool passed, bytes32 reasonCode) = _check(); + assertFalse(passed); + assertEq(reasonCode, _code(n)); + } + + function _assertPass() internal { + (bool passed, bytes32 reasonCode) = _check(); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // --- metadata / construction ---------------------------------------- + + function test_metadata_fields() public { + ElementMetadata memory m = element.elementMetadata(); + assertEq(m.elementId, bytes32("A-08-v1")); + assertEq(uint256(m.category), uint256(ElementCategory.INVESTOR_ATTRIBUTE)); + assertEq(uint256(m.temporal), uint256(TemporalNature.ONE_TIME)); + assertEq(uint256(m.decidability), uint256(Decidability.ATTESTATION_BASED)); + assertEq(uint256(m.timing), uint256(ObligationTiming.EX_ANTE_VERIFY)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + } + + function test_constructor_revertsOnZeroLookThroughSource() public { + vm.expectRevert(EntityEligibility.ZeroLookThroughSource.selector); + new EntityEligibility(ILookThroughSource(address(0))); + } + + function test_constructor_storesLookThroughSource() public { + assertEq(address(element.lookThroughSource()), address(look)); + } + + // --- auth / events --------------------------------------------------- + + function test_setEntityClaim_revertsForNonOperator() public { + EntityEligibility.EntityClaim memory c = + _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.QIB, 0, false, true, true); + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setEntityClaim(buyer, c); + } + + function test_setRequiredTracks_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setRequiredTracks(asset, TRACK_QP); + } + + function test_setEntityClaim_emitsEvent() public { + EntityEligibility.EntityClaim memory c = _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 10_000_000, + false, + false, + true + ); + vm.expectEmit(true, false, false, true); + emit EntityClaimSet( + buyer, + true, + uint8(EntityEligibility.AiBasis.DIRECT_ASSETS), + uint8(EntityEligibility.QpBasis.FAMILY_COMPANY), + 10_000_000, + false, + false, + true + ); + element.setEntityClaim(buyer, c); + } + + function test_setRequiredTracks_emitsEvent_andOperatorCanSet() public { + element.setOperator(operator, true); + vm.expectEmit(true, false, false, true); + emit RequiredTracksSet(asset, TRACK_BOTH); + vm.prank(operator); + element.setRequiredTracks(asset, TRACK_BOTH); + assertEq(element.requiredTracksOf(asset), TRACK_BOTH); + } + + // --- dormancy -------------------------------------------------------- + + function test_dormant_noTracks() public { + // Claim present, but no track armed for the asset => PASS. + element.setEntityClaim( + buyer, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.NONE, 0, false, false, false) + ); + _assertPass(); + } + + function test_dormant_naturalPerson() public { + // Tracks armed, but buyer is not an entity => PASS (A-03/A-13 decide). + element.setRequiredTracks(asset, TRACK_BOTH); + EntityEligibility.EntityClaim memory c = + _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.NONE, 0, false, false, false); + c.isEntity = false; + element.setEntityClaim(buyer, c); + _assertPass(); + } + + // --- code 1: CATEGORY_MISMATCH -------------------------------------- + + function test_code1_aiBasisNone() public { + _set(TRACK_AI, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.NONE, 0, false, false, true)); + _assertFail(1); + } + + function test_code1_qpBasisNone() public { + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.NONE, 0, false, false, true)); + _assertFail(1); + } + + // --- code 2: THRESHOLD_NOT_MET -------------------------------------- + + function test_code2_aiDirectBelow() public { + _set( + TRACK_AI, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, EntityEligibility.QpBasis.NONE, 4_900_000, false, false, true + ) + ); + _assertFail(2); + } + + function test_code2_qpFamilyBelow() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.FAMILY_COMPANY, 4_999_999, false, false, true + ) + ); + _assertFail(2); + } + + function test_code2_qpInstitutionalBelow() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.INSTITUTIONAL, 24_999_999, false, false, true + ) + ); + _assertFail(2); + } + + // --- code 3: QIB_UNCONFIRMED ---------------------------------------- + + function test_code3_qibUnconfirmed() public { + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.QIB, 0, false, false, true)); + _assertFail(3); + } + + // --- code 4: FORMED_FOR_PURPOSE ------------------------------------- + + function test_code4_aiDirectFormedForPurpose_barredEvenWithAmple() public { + // T10: assets ample ($8M) but formed-for-purpose LLC via direct path. + _set( + TRACK_AI, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, EntityEligibility.QpBasis.NONE, 8_000_000, true, false, true + ) + ); + _assertFail(4); + } + + function test_code4_qpTrustFormedForPurpose_noCure() public { + // Trust FFP: no look-through cure even if A-09 is COMPLETED. + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.TRUST, 0, true, false, true)); + _assertFail(4); + } + + // --- code 5 / 6: LOOKTHROUGH_REQUIRED vs FAILED --------------------- + + function test_code5_aiAllOwnersNoneStatus() public { + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, false, false, true) + ); + // default status NONE + _assertFail(5); + } + + function test_code5_aiAllOwnersPending() public { + look.setStatus(buyer, LookThroughStatus.PENDING); + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, false, false, true) + ); + _assertFail(5); + } + + function test_code6_aiAllOwnersFailed() public { + look.setStatus(buyer, LookThroughStatus.FAILED); + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, false, false, true) + ); + _assertFail(6); + } + + function test_code5_qpTrustPending() public { + look.setStatus(buyer, LookThroughStatus.PENDING); + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.TRUST, 0, false, false, true)); + _assertFail(5); + } + + function test_code6_qpTrustFailed() public { + look.setStatus(buyer, LookThroughStatus.FAILED); + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.TRUST, 0, false, false, true)); + _assertFail(6); + } + + function test_qpTrustCompleted_passes() public { + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.TRUST, 0, false, false, true)); + _assertPass(); + } + + // company FFP IS cured by look-through COMPLETED (asymmetry vs trust) + function test_qpFamilyFormedForPurpose_curedByLookThrough() public { + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.FAMILY_COMPANY, 10_000_000, true, false, true + ) + ); + _assertPass(); + } + + function test_qpFamilyFormedForPurpose_pendingRequiresLookThrough() public { + look.setStatus(buyer, LookThroughStatus.PENDING); + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.FAMILY_COMPANY, 10_000_000, true, false, true + ) + ); + _assertFail(5); + } + + // --- code 7: DIRECT_REQ_MISSING ------------------------------------- + + function test_code7_qpFamilyDirectReqMissing() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 10_000_000, + false, + false, + false + ) + ); + _assertFail(7); + } + + function test_code7_aiAllOwnersDirectReqMissing() public { + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, false, false, false) + ); + _assertFail(7); + } + + // --- boundaries (doc §5.3) ------------------------------------------ + + function test_boundary_aiDirectExactly5M_fails() public { + // STRICT `>`: exactly $5,000,000 FAILs (code 2). + _set( + TRACK_AI, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, EntityEligibility.QpBasis.NONE, 5_000_000, false, false, true + ) + ); + _assertFail(2); + } + + function test_boundary_qpFamilyExactly5M_passes() public { + // INCLUSIVE `>=`: exactly $5,000,000 PASSes. + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.FAMILY_COMPANY, 5_000_000, false, false, true + ) + ); + _assertPass(); + } + + function test_boundary_qpInstitutionalExactly25M_passes() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.INSTITUTIONAL, 25_000_000, false, true, true + ) + ); + _assertPass(); + } + + // --- happy paths ----------------------------------------------------- + + function test_pass_qpFamily() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.FAMILY_COMPANY, 10_000_000, false, false, true + ) + ); + _assertPass(); + } + + function test_pass_qpQib_skipsAmount() public { + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.QIB, 0, false, true, true)); + _assertPass(); + } + + function test_pass_aiAllOwnersCompleted() public { + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, false, false, true) + ); + _assertPass(); + } + + // T11: formed-for-purpose LLC re-submitted as (a)(8), all owners AI => PASS. + function test_pass_aiFormedForPurpose_curedByAllOwners() public { + look.setStatus(buyer, LookThroughStatus.COMPLETED); + _set( + TRACK_AI, + _claim(EntityEligibility.AiBasis.ALL_OWNERS_AI, EntityEligibility.QpBasis.NONE, 0, true, false, true) + ); + _assertPass(); + } + + // --- T4 signature case & AND gate (code 8) -------------------------- + + // Exactly $5M family company: R3-only PASSes; R1+R3 fails AI(strict) => 8. + function test_t4_qpFamilyExactly5M_r3Only_passes() public { + _set( + TRACK_QP, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 5_000_000, + false, + false, + true + ) + ); + _assertPass(); + } + + function test_t4_qpFamilyExactly5M_bothTracks_andGateFails() public { + _set( + TRACK_BOTH, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 5_000_000, + false, + false, + true + ) + ); + // QP family >= 5M passes, AI direct <= 5M fails (strict) => AND gate code 8. + _assertFail(8); + } + + function test_andGate_bothPass() public { + // AI direct $6M (> 5M) and QP family $6M (>= 5M) both pass => PASS. + _set( + TRACK_BOTH, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 6_000_000, + false, + false, + true + ) + ); + _assertPass(); + } + + // --- diagnose companion --------------------------------------------- + + function test_diagnose_emitsPerTrackCodes_onAndGateFail() public { + _set( + TRACK_BOTH, + _claim( + EntityEligibility.AiBasis.DIRECT_ASSETS, + EntityEligibility.QpBasis.FAMILY_COMPANY, + 5_000_000, + false, + false, + true + ) + ); + vm.expectEmit(true, true, false, true); + emit EntityAndGateDiagnostic(buyer, asset, 2, 0); // AI fails threshold (2), QP ok (0) + (bool passed, bytes32 reasonCode) = element.diagnose(buyer, asset); + assertFalse(passed); + assertEq(reasonCode, _code(8)); + } + + function test_diagnose_matchesCheck_onPass() public { + _set(TRACK_QP, _claim(EntityEligibility.AiBasis.NONE, EntityEligibility.QpBasis.QIB, 0, false, true, true)); + (bool passed, bytes32 reasonCode) = element.diagnose(buyer, asset); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } +} diff --git a/test/unit/compliance/elements/EquityOwnerLookThrough.t.sol b/test/unit/compliance/elements/EquityOwnerLookThrough.t.sol new file mode 100644 index 0000000..dfb2cdf --- /dev/null +++ b/test/unit/compliance/elements/EquityOwnerLookThrough.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {EquityOwnerLookThrough} from "../../../../src/compliance/elements/EquityOwnerLookThrough.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import { + ElementMetadata, + ElementCategory, + Decidability, + ObligationTiming, + Statefulness, + TemporalNature +} from "../../../../src/types/ComplianceTypes.sol"; +import {ILookThroughSource, LookThroughStatus} from "../../../../src/interfaces/compliance/ILookThroughSource.sol"; + +contract EquityOwnerLookThroughTest is Test { + // Re-declared to match EquityOwnerLookThrough.LookThroughStatusSet for + // vm.expectEmit (Solidity 0.8.17 cannot reference a non-library contract's + // event by qualified name in an `emit` statement; that requires >=0.8.22). + event LookThroughStatusSet(address indexed subject, LookThroughStatus status); + + bytes32 internal constant ELEMENT_ID = "A-09-v1"; + + address internal subject = address(0xA11CE); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + EquityOwnerLookThrough internal element; + + function setUp() public { + element = new EquityOwnerLookThrough(); + } + + function _reasonCode(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), ELEMENT_ID, n)); + } + + function test_metadata_fields() public { + ElementMetadata memory m = element.elementMetadata(); + assertEq(m.elementId, ELEMENT_ID); + assertEq(uint256(m.category), uint256(ElementCategory.INVESTOR_ATTRIBUTE)); + assertEq(uint256(m.decidability), uint256(Decidability.ATTESTATION_BASED)); + assertEq(uint256(m.timing), uint256(ObligationTiming.EX_ANTE_VERIFY)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + assertEq(uint256(m.temporal), uint256(TemporalNature.ONE_TIME)); + } + + function test_setLookThroughStatus_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + } + + function test_setLookThroughStatus_ownerCanSet_andEmitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit LookThroughStatusSet(subject, LookThroughStatus.COMPLETED); + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + + assertEq(uint256(element.statusOf(subject)), uint256(LookThroughStatus.COMPLETED)); + } + + function test_setLookThroughStatus_operatorCanSet() public { + element.setOperator(operator, true); + + vm.prank(operator); + element.setLookThroughStatus(subject, LookThroughStatus.PENDING); + + assertEq(uint256(element.statusOf(subject)), uint256(LookThroughStatus.PENDING)); + } + + // --- default / dormancy ------------------------------------------------- + + function test_check_defaultStatusIsNone_andPasses() public { + assertEq(uint256(element.statusOf(subject)), uint256(LookThroughStatus.NONE)); + + (bool passed, bytes32 reasonCode) = element.check(subject, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // --- all four statuses through check ------------------------------------ + + function test_check_passesWhenCompleted() public { + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + + (bool passed, bytes32 reasonCode) = element.check(subject, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_failsWithCode1WhenPending() public { + element.setLookThroughStatus(subject, LookThroughStatus.PENDING); + + (bool passed, bytes32 reasonCode) = element.check(subject, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(1)); + } + + function test_check_failsWithCode2WhenFailed() public { + element.setLookThroughStatus(subject, LookThroughStatus.FAILED); + + (bool passed, bytes32 reasonCode) = element.check(subject, address(0), address(0), 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(2)); + } + + // --- status transitions + revocation ------------------------------------ + + function test_statusTransition_pendingToCompleted_thenCheckPasses() public { + element.setLookThroughStatus(subject, LookThroughStatus.PENDING); + (bool passedPending,) = element.check(subject, address(0), address(0), 0, ""); + assertFalse(passedPending); + + vm.expectEmit(true, false, false, true); + emit LookThroughStatusSet(subject, LookThroughStatus.COMPLETED); + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + + (bool passedCompleted,) = element.check(subject, address(0), address(0), 0, ""); + assertTrue(passedCompleted); + } + + function test_revocation_completedBackToNone_emitsEvent_andPasses() public { + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + + // Explicit reset to NONE is a valid revocation, not a no-op skip, and + // must emit like any other transition. + vm.expectEmit(true, false, false, true); + emit LookThroughStatusSet(subject, LookThroughStatus.NONE); + element.setLookThroughStatus(subject, LookThroughStatus.NONE); + + assertEq(uint256(element.statusOf(subject)), uint256(LookThroughStatus.NONE)); + + (bool passed, bytes32 reasonCode) = element.check(subject, address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_revocation_failedBackToNone_unblocksCheck() public { + element.setLookThroughStatus(subject, LookThroughStatus.FAILED); + (bool blocked,) = element.check(subject, address(0), address(0), 0, ""); + assertFalse(blocked); + + element.setLookThroughStatus(subject, LookThroughStatus.NONE); + + (bool passed,) = element.check(subject, address(0), address(0), 0, ""); + assertTrue(passed); + } + + // --- interface conformance ----------------------------------------------- + + function test_interfaceConformance_ILookThroughSource() public { + element.setLookThroughStatus(subject, LookThroughStatus.FAILED); + + LookThroughStatus status = ILookThroughSource(address(element)).statusOf(subject); + assertEq(uint256(status), uint256(LookThroughStatus.FAILED)); + } + + function test_check_ignoresCounterpartyAssetAmountAndContext() public { + element.setLookThroughStatus(subject, LookThroughStatus.COMPLETED); + + (bool passed1,) = element.check(subject, address(0), address(0), 0, ""); + (bool passed2,) = element.check(subject, address(0xDEAD), address(0xBEEF), 12345, "abc"); + assertTrue(passed1); + assertTrue(passed2); + assertEq(passed1, passed2); + } +} diff --git a/test/unit/compliance/elements/HolderCount.t.sol b/test/unit/compliance/elements/HolderCount.t.sol new file mode 100644 index 0000000..47e92ea --- /dev/null +++ b/test/unit/compliance/elements/HolderCount.t.sol @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {HolderCount, IIdentityView, IAiView} from "../../../../src/compliance/elements/HolderCount.sol"; +import {IdentityUniqueness} from "../../../../src/compliance/elements/IdentityUniqueness.sol"; +import {AccreditedInvestor} from "../../../../src/compliance/elements/AccreditedInvestor.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../../../src/types/ComplianceTypes.sol"; + +contract HolderCountTest is Test { + // Re-declared to match HolderCount's own events for vm.expectEmit — Solidity + // 0.8.17 cannot `emit` an event declared on another contract by qualified name. + event HolderCountChanged( + bytes32 indexed identityId, address indexed wallet, bool entered, uint256 holderCount, uint256 nonAiCount + ); + + // REAL A-04 / A-03 fixtures (not mocks), per the plan. + IdentityUniqueness internal ident; + AccreditedInvestor internal air; + HolderCount internal hc; + + address internal engine = address(0xE); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + function setUp() public { + ident = new IdentityUniqueness(); + air = new AccreditedInvestor(); + // Default mode TWELVE_G; individual tests re-set via setCapMode. + hc = new HolderCount(HolderCount.CapMode.TWELVE_G, IIdentityView(address(ident)), IAiView(address(air))); + hc.setEngine(engine); // owner-gated (test contract is owner) + hc.setOperator(operator, true); + } + + // reasonCode recompute per plan: keccak256(abi.encode(uint16(0), id, uint32(n))). + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("D-01-v1"), n)); + } + + function _wallet(uint256 i) internal pure returns (address) { + return address(uint160(0x100000 + i)); + } + + // Mint one unit to `to` as the engine (new holder entry when to is fresh). + function _mint(address to) internal { + vm.prank(engine); + hc.onTransfer(address(0), to, 1); + } + + // ------------------------------------------------------------------------- + // metadata + // ------------------------------------------------------------------------- + + function test_metadata_fields() public view { + ElementMetadata memory m = hc.elementMetadata(); + assertEq(m.elementId, bytes32("D-01-v1")); + assertEq(uint256(m.category), uint256(ElementCategory.SYSTEM_STATE)); + assertEq(uint256(m.temporal), uint256(TemporalNature.CUMULATIVE)); + assertEq(uint256(m.decidability), uint256(Decidability.DETERMINISTIC)); + assertEq(uint256(m.timing), uint256(ObligationTiming.AT_TRADE_GATE)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATEFUL)); + } + + function test_constructor_revertsOnZeroIdentity() public { + vm.expectRevert(HolderCount.ZeroDependency.selector); + new HolderCount(HolderCount.CapMode.NONE, IIdentityView(address(0)), IAiView(address(air))); + } + + function test_constructor_revertsOnZeroAi() public { + vm.expectRevert(HolderCount.ZeroDependency.selector); + new HolderCount(HolderCount.CapMode.NONE, IIdentityView(address(ident)), IAiView(address(0))); + } + + // ------------------------------------------------------------------------- + // auth + // ------------------------------------------------------------------------- + + function test_onTransfer_revertsForNonEngine() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + hc.onTransfer(address(0), _wallet(1), 1); + } + + function test_setCapMode_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + hc.setCapMode(HolderCount.CapMode.THREE_C_1); + } + + function test_setAssetGateMet_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + hc.setAssetGateMet(true); + } + + function test_setOperator_revertsForNonOwner() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + hc.setOperator(stranger, true); + } + + function test_operator_canSetCapMode() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.THREE_C_1); + assertEq(uint256(hc.capMode()), uint256(HolderCount.CapMode.THREE_C_1)); + } + + // ------------------------------------------------------------------------- + // check: cheap paths + // ------------------------------------------------------------------------- + + function test_check_amountZero_passes() public { + // Even in a mode with the asset gate on, a zero-amount trade never counts. + vm.prank(operator); + hc.setAssetGateMet(true); + (bool passed, bytes32 rc) = hc.check(_wallet(1), address(0), address(0), 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_check_capModeNone_passesEverything() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.NONE); + (bool passed,) = hc.check(_wallet(1), address(0), address(0), 1, ""); + assertTrue(passed); + } + + function test_twelveG_assetGateFalse_passesEverything() public { + // assetGateMet defaults false => Rule 12g-1(a) exemption => always PASS, + // and commit never reverts, even past the total cap. + assertFalse(hc.assetGateMet()); + for (uint256 i = 0; i < 5; i++) { + (bool passed,) = hc.check(_wallet(i), address(0), address(0), 1, ""); + assertTrue(passed); + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 5); + } + + // ------------------------------------------------------------------------- + // P1: existing holder passes without count effect + // ------------------------------------------------------------------------- + + function test_existingHolder_passesWithoutCountEffect() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.FIVE_06_B); + // Fill to exactly the cap (35 holders). + for (uint256 i = 0; i < 35; i++) { + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 35); + + // A transfer to an already-counted holder passes even at the cap... + (bool passed, bytes32 rc) = hc.check(_wallet(0), address(0), address(0), 1, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + + // ...and committing it does not change the count. + vm.prank(engine); + hc.onTransfer(address(0), _wallet(0), 5); + assertEq(hc.holderCount(), 35); + } + + // ------------------------------------------------------------------------- + // dedup via REAL IdentityUniqueness + // ------------------------------------------------------------------------- + + function test_dedup_boundWallet_countsUnderIdentityNotAddress() public { + // NOTE: the real A-04 IdentityUniqueness mock enforces a STRICT 1:1 + // wallet<->identity binding (walletOf[id] holds a single wallet), so two + // distinct wallets sharing one identity is not constructible with the real + // fixture (see deviation note). What IS observable, and is the core dedup + // property D-01 relies on, is that counting is keyed by the A-04 identity + // id, NOT the raw wallet address. + bytes32 idP = keccak256("person-P"); + address wP = _wallet(10); + ident.bindIdentity(wP, idP); // test contract is IdentityUniqueness owner + _mint(wP); + assertEq(hc.holderCount(), 1); + assertEq(hc.balanceOfIdentity(idP), 1); // counted under the identity id + assertEq(hc.balanceOfIdentity(bytes32(uint256(uint160(wP)))), 0); // NOT the address + + // A second unit to the same bound wallet: same identity already holds => + // P1 pass, no new holder. + (bool passed,) = hc.check(wP, address(0), address(0), 1, ""); + assertTrue(passed); + vm.prank(engine); + hc.onTransfer(address(0), wP, 1); + assertEq(hc.holderCount(), 1); + } + + function test_unboundWallet_countsAsItself() public { + // No binding: identity falls back to bytes32(uint160(wallet)). + address w = _wallet(7); + _mint(w); + assertEq(hc.holderCount(), 1); + assertEq(hc.balanceOfIdentity(bytes32(uint256(uint160(w)))), 1); + + address w2 = _wallet(8); + _mint(w2); + assertEq(hc.holderCount(), 2); + } + + // ------------------------------------------------------------------------- + // THREE_C_1 boundary (100): 100th PASS / 101st FAIL(3) + // ------------------------------------------------------------------------- + + function test_threeC1_boundary_100pass_101fail() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.THREE_C_1); + + // Enter 99 holders; the 100th entrant is still within <= 100. + for (uint256 i = 0; i < 99; i++) { + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 99); + + // 100th new person: resulting 100, 100 > 100 is false => PASS. + (bool pass100, bytes32 rc100) = hc.check(_wallet(99), address(0), address(0), 1, ""); + assertTrue(pass100); + assertEq(rc100, bytes32(0)); + _mint(_wallet(99)); + assertEq(hc.holderCount(), 100); + + // 101st new person: resulting 101 > 100 => FAIL code 3. + (bool pass101, bytes32 rc101) = hc.check(_wallet(100), address(0), address(0), 1, ""); + assertFalse(pass101); + assertEq(rc101, _code(3)); + } + + // ------------------------------------------------------------------------- + // FIVE_06_B boundary (35): 35th PASS / 36th FAIL(4) + // ------------------------------------------------------------------------- + + function test_fiveOhSixB_boundary_35pass_36fail() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.FIVE_06_B); + + for (uint256 i = 0; i < 34; i++) { + _mint(_wallet(i)); + } + (bool pass35,) = hc.check(_wallet(34), address(0), address(0), 1, ""); + assertTrue(pass35); + _mint(_wallet(34)); + assertEq(hc.holderCount(), 35); + + (bool pass36, bytes32 rc36) = hc.check(_wallet(35), address(0), address(0), 1, ""); + assertFalse(pass36); + assertEq(rc36, _code(4)); + } + + // ------------------------------------------------------------------------- + // TWELVE_G non-AI 500 cap (code 2) + AI buyers don't consume the non-AI budget + // ------------------------------------------------------------------------- + + function test_twelveG_nonAiCap_500_andAiDoesNotConsumeBudget() public { + vm.prank(operator); + hc.setAssetGateMet(true); // TWELVE_G already set in setUp + + // 499 non-accredited holders (accredited defaults false). + for (uint256 i = 0; i < 499; i++) { + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 499); + assertEq(hc.nonAiCount(), 499); + + // An AI buyer passes (total 500 < 2000) and does NOT touch the non-AI budget. + address aiBuyer = _wallet(1000); + air.setAccredited(aiBuyer, true); + (bool passAi,) = hc.check(aiBuyer, address(0), address(0), 1, ""); + assertTrue(passAi); + _mint(aiBuyer); + assertEq(hc.holderCount(), 500); + assertEq(hc.nonAiCount(), 499); // unchanged + + // A further non-AI buyer: nonAi 499 + 1 == 500 >= 500 => FAIL code 2, + // even though total (501) is far below 2000. + (bool passNon, bytes32 rc) = hc.check(_wallet(2000), address(0), address(0), 1, ""); + assertFalse(passNon); + assertEq(rc, _code(2)); + } + + // ------------------------------------------------------------------------- + // TWELVE_G total cap (code 1): 1999th PASS / 2000th FAIL(1) + // ------------------------------------------------------------------------- + + function test_twelveG_totalCap_2000_boundary() public { + vm.prank(operator); + hc.setAssetGateMet(true); + + // Enter 1999 AI holders (so the non-AI budget never trips first). + for (uint256 i = 0; i < 1999; i++) { + address w = _wallet(i); + air.setAccredited(w, true); + _mint(w); + } + assertEq(hc.holderCount(), 1999); + + // 1999 holders is still < 2000 (the 1999th entry above passed at commit). + // The 2000th new person: resulting 2000 >= 2000 => FAIL code 1. + address next = _wallet(5000); + air.setAccredited(next, true); + (bool passed, bytes32 rc) = hc.check(next, address(0), address(0), 1, ""); + assertFalse(passed); + assertEq(rc, _code(1)); + } + + // ------------------------------------------------------------------------- + // commit-time re-validation: engine drives a past-cap entry directly => revert + // ------------------------------------------------------------------------- + + function test_commit_reValidates_revertsComplianceRejected() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.FIVE_06_B); + for (uint256 i = 0; i < 35; i++) { + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 35); + + // Even though check would reject, exercise onTransfer directly (gate bypass + // / gate-to-commit race): the commit re-validates and reverts. + vm.prank(engine); + vm.expectRevert(abi.encodeWithSelector(Errors.ComplianceRejected.selector, _code(4))); + hc.onTransfer(address(0), _wallet(100), 1); + } + + // ------------------------------------------------------------------------- + // exit decrement frees a slot + // ------------------------------------------------------------------------- + + function test_exit_freesSlot_forNewEntrant() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.FIVE_06_B); + for (uint256 i = 0; i < 35; i++) { + _mint(_wallet(i)); + } + assertEq(hc.holderCount(), 35); + + // 36th new entrant is blocked. + (bool blocked,) = hc.check(_wallet(100), address(0), address(0), 1, ""); + assertFalse(blocked); + + // Holder 0 sells out to an EXISTING holder (1): no new entry, holder 0 exits. + vm.prank(engine); + hc.onTransfer(_wallet(0), _wallet(1), 1); + assertEq(hc.holderCount(), 34); + + // Now the previously-blocked entrant passes and can commit. + (bool nowOk,) = hc.check(_wallet(100), address(0), address(0), 1, ""); + assertTrue(nowOk); + _mint(_wallet(100)); + assertEq(hc.holderCount(), 35); + } + + // ------------------------------------------------------------------------- + // AI-snapshot invariant: a post-entry accredited flip must not desync the + // non-AI decrement on exit. + // ------------------------------------------------------------------------- + + function test_aiSnapshot_invariant_flipAfterEntry_exitStillDecrements() public { + vm.prank(operator); + hc.setAssetGateMet(true); // TWELVE_G + + address w = _wallet(1); + // Enter as NON-accredited => counted in the non-AI budget. + _mint(w); + assertEq(hc.holderCount(), 1); + assertEq(hc.nonAiCount(), 1); + assertTrue(hc.countedNonAi(bytes32(uint256(uint160(w))))); + + // Flip to accredited AFTER entry. A live read on exit would (wrongly) skip + // the non-AI decrement; the snapshot must win. + air.setAccredited(w, true); + + // Burn (sell out): exit decrements holderCount and, via the snapshot, + // nonAiCount back to 0. + vm.prank(engine); + hc.onTransfer(w, address(0), 1); + assertEq(hc.holderCount(), 0); + assertEq(hc.nonAiCount(), 0); + assertFalse(hc.countedNonAi(bytes32(uint256(uint160(w))))); + } + + // ------------------------------------------------------------------------- + // event emission on entry / exit + // ------------------------------------------------------------------------- + + function test_holderCountChanged_events() public { + vm.prank(operator); + hc.setCapMode(HolderCount.CapMode.NONE); + address w = _wallet(1); + bytes32 id = bytes32(uint256(uint160(w))); + + vm.expectEmit(true, true, false, true); + emit HolderCountChanged(id, w, true, 1, 1); + vm.prank(engine); + hc.onTransfer(address(0), w, 1); + + vm.expectEmit(true, true, false, true); + emit HolderCountChanged(id, w, false, 0, 0); + vm.prank(engine); + hc.onTransfer(w, address(0), 1); + } +} diff --git a/test/unit/compliance/elements/TransferRestrictionMetadata.t.sol b/test/unit/compliance/elements/TransferRestrictionMetadata.t.sol new file mode 100644 index 0000000..93a6918 --- /dev/null +++ b/test/unit/compliance/elements/TransferRestrictionMetadata.t.sol @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {TransferRestrictionMetadata} from "../../../../src/compliance/elements/TransferRestrictionMetadata.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import {ElementMetadata, ElementCategory, Decidability, Statefulness} from "../../../../src/types/ComplianceTypes.sol"; + +contract TransferRestrictionMetadataTest is Test { + // Re-declared to match the contract's events for vm.expectEmit (Solidity + // 0.8.17 cannot reference a non-library contract's event by qualified name + // in an `emit` statement; that requires >=0.8.22). + event DeclarationSet(address indexed asset, bytes32 declarationHash); + event RequiresRestrictedSet(bytes32 indexed issuanceFramework, bool required); + event ValidPathsMaskSet(uint32 mask); + event ApprovedUnrestrictBasisSet(bytes32 indexed basisRef, bool approved); + + bytes32 internal constant ELEMENT_ID = "B-03-v1"; + + bytes32 internal constant REG_D = bytes32("REG_D"); // requiresRestricted = true + bytes32 internal constant REG_A = bytes32("REG_A"); // never set => requiresRestricted = false (lax) + + uint32 internal constant PATH_RULE144 = 1; // bit0 + uint32 internal constant PATH_4A7 = 2; // bit1 + uint32 internal constant PATH_144A = 4; // bit2 + uint32 internal constant PATH_UNKNOWN = 8; // bit3 — never in validPathsMask + uint32 internal constant VALID_PATHS_MASK = PATH_RULE144 | PATH_4A7 | PATH_144A; // 0x07 + + bytes32 internal constant CLASS_ID = bytes32("CLASS-1"); + bytes32 internal constant LEGEND_REF = bytes32("legend-hash"); + bytes32 internal constant BASIS_REF = bytes32("opinion+ta-hash"); + + address internal asset = address(0xBEEF); + address internal undeclaredAsset = address(0xCAFE); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + TransferRestrictionMetadata internal element; + + function setUp() public { + element = new TransferRestrictionMetadata(); + element.setRequiresRestricted(REG_D, true); + element.setValidPathsMask(VALID_PATHS_MASK); + } + + function _reasonCode(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), ELEMENT_ID, n)); + } + + /// @dev A fully-compliant restricted declaration: REG_D framework, valid + /// paths, 12-month (non-reporting) holding period, complete tags, no + /// internal conflicts. Each test starts from this and mutates one axis. + function _validRestrictedDecl() internal pure returns (TransferRestrictionMetadata.RestrictionDecl memory) { + return TransferRestrictionMetadata.RestrictionDecl({ + declared: true, + restrictedFlag: true, + issuanceFramework: REG_D, + enabledResalePaths: PATH_RULE144 | PATH_4A7, + holdingPeriodMonths: 12, + reportingStatus: TransferRestrictionMetadata.ReportingStatus.NON_REPORTING, + currentInfoRequired: false, + legendRef: LEGEND_REF, + classRef: CLASS_ID, + legalClassId: CLASS_ID, + unrestrictBasisRef: bytes32(0) + }); + } + + // --------------------------------------------------------------------- + // Metadata + // --------------------------------------------------------------------- + + function test_metadata_fields() public { + ElementMetadata memory m = element.elementMetadata(); + assertEq(m.elementId, ELEMENT_ID); + assertEq(uint256(m.category), uint256(ElementCategory.ASSET_ATTRIBUTE)); + assertEq(uint256(m.decidability), uint256(Decidability.DETERMINISTIC)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + } + + // --------------------------------------------------------------------- + // Auth gating — every setter reverts for non-operator, succeeds + emits + // for owner/operator. + // --------------------------------------------------------------------- + + function test_setDeclaration_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setDeclaration(asset, _validRestrictedDecl()); + } + + function test_setDeclaration_ownerCanSet_andEmitsEvent() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + vm.expectEmit(true, false, false, true); + emit DeclarationSet(asset, keccak256(abi.encode(decl))); + element.setDeclaration(asset, decl); + + (bool declared, bool restrictedFlag,,,,,,,,,) = element.declarationOf(asset); + assertTrue(declared); + assertTrue(restrictedFlag); + } + + function test_setDeclaration_operatorCanSet() public { + element.setOperator(operator, true); + vm.prank(operator); + element.setDeclaration(asset, _validRestrictedDecl()); + + (bool declared,,,,,,,,,,) = element.declarationOf(asset); + assertTrue(declared); + } + + function test_setRequiresRestricted_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setRequiresRestricted(REG_D, true); + } + + function test_setRequiresRestricted_emitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit RequiresRestrictedSet(REG_A, true); + element.setRequiresRestricted(REG_A, true); + assertTrue(element.requiresRestricted(REG_A)); + } + + function test_setValidPathsMask_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setValidPathsMask(PATH_RULE144); + } + + function test_setValidPathsMask_emitsEvent_andUpdatesStorage() public { + vm.expectEmit(false, false, false, true); + emit ValidPathsMaskSet(PATH_RULE144); + element.setValidPathsMask(PATH_RULE144); + assertEq(element.validPathsMask(), PATH_RULE144); + } + + function test_setApprovedUnrestrictBasis_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setApprovedUnrestrictBasis(BASIS_REF, true); + } + + function test_setApprovedUnrestrictBasis_emitsEvent_andUpdatesStorage() public { + vm.expectEmit(true, false, false, true); + emit ApprovedUnrestrictBasisSet(BASIS_REF, true); + element.setApprovedUnrestrictBasis(BASIS_REF, true); + assertTrue(element.approvedUnrestrictBasis(BASIS_REF)); + } + + // --------------------------------------------------------------------- + // Test 1 (doc §7.1) — clean pass + // --------------------------------------------------------------------- + + function test_check_passes_onFullyCompliantDeclaration() public { + element.setDeclaration(asset, _validRestrictedDecl()); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_ignoresNonAssetParameters() public { + element.setDeclaration(asset, _validRestrictedDecl()); + + (bool passed1,) = element.check(address(0x1), address(0), asset, 0, ""); + (bool passed2,) = element.check(address(0x2), address(0x3), asset, 999, hex"1234"); + assertTrue(passed1); + assertTrue(passed2); + } + + // --------------------------------------------------------------------- + // Test 8 (doc §7.8) / gate ① — missing-vs-false distinction + // --------------------------------------------------------------------- + + function test_check_fails_declMissing_whenUndeclared() public { + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), undeclaredAsset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(1)); + } + + // --------------------------------------------------------------------- + // Test 2 (doc §7.2) / gate ② — false-relaxation fail, and the + // missing-vs-false pairing (declared + false + required => code 2, not 1) + // --------------------------------------------------------------------- + + function test_check_fails_statusConflict_whenRequiredButFlagFalse() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.restrictedFlag = false; + decl.unrestrictBasisRef = bytes32(0); // even absent, ② fires first + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(2)); + } + + function test_check_statusConflict_takesPrecedenceOver_unrestrictBasisMissing() public { + // Even with an approved basis ref registered, ② (declared as false + // relaxation against a required-restricted framework) must fire + // before ⑤ is ever reached. + element.setApprovedUnrestrictBasis(BASIS_REF, true); + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.restrictedFlag = false; + decl.unrestrictBasisRef = BASIS_REF; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(2)); + } + + // --------------------------------------------------------------------- + // Direction matrix / gate ② — hardening (required lax + flag true) passes + // --------------------------------------------------------------------- + + function test_check_passes_onHardeningDirection_requiredLaxButFlagTrue() public { + // REG_A was never registered in requiresRestricted => lax (false). + // Declaring restrictedFlag = true anyway is conservative overclaim, + // not a conflict, and must pass (no symmetric check in this direction). + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.issuanceFramework = REG_A; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // --------------------------------------------------------------------- + // Test 3 (doc §7.3) / gate ③a — empty path set fails even though the + // empty set is vacuously a subset of validPathsMask (regression pin). + // --------------------------------------------------------------------- + + function test_check_fails_tagsIncomplete_onEmptyResalePaths() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.enabledResalePaths = 0; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(3)); + } + + // --------------------------------------------------------------------- + // Test 6 (doc §7.6) / gate ③a — unknown path bit fails, not partial-match + // --------------------------------------------------------------------- + + function test_check_fails_tagInvalid_onUnknownPathBit() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.enabledResalePaths = PATH_RULE144 | PATH_UNKNOWN; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(4)); + } + + // --------------------------------------------------------------------- + // Test 6 (doc §7.6) / gate ③b — holding period is SET MEMBERSHIP in + // {6, 12}, not a magnitude comparison (9 is not "close enough") + // --------------------------------------------------------------------- + + function test_check_fails_tagInvalid_onHoldingPeriodOutsideLegalSet() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.holdingPeriodMonths = 9; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(4)); + } + + // --------------------------------------------------------------------- + // Gate ③ remaining completeness checks (reportingStatus / classRef / legendRef) + // --------------------------------------------------------------------- + + function test_check_fails_tagsIncomplete_onUnsetReportingStatus() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.UNSET; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(3)); + } + + function test_check_fails_tagsIncomplete_onClassRefMismatch() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.classRef = bytes32("WRONG-CLASS"); + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(3)); + } + + function test_check_fails_tagsIncomplete_onMissingLegendRef() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.legendRef = bytes32(0); + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(3)); + } + + // --------------------------------------------------------------------- + // Test 4 (doc §7.4) / gate ④a — relaxing conflict fails, hardening passes + // --------------------------------------------------------------------- + + function test_check_fails_tagConflict_onNonReportingWithSixMonths() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.NON_REPORTING; + decl.holdingPeriodMonths = 6; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(5)); + } + + function test_check_passes_onReportingWithTwelveMonths_conservativeExcess() public { + // Reporting issuer's statutory floor is 6 months; declaring 12 is a + // stricter (conservative, contractual lock-up) setting and must pass. + // Paired regression with the case above (doc §7.4) — a common + // over-implementation symmetrically forces "reporting => 6" as well, + // which this test guards against. + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.REPORTING_90D; + decl.holdingPeriodMonths = 12; + decl.currentInfoRequired = true; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_check_passes_onReportingWithSixMonths_naturalMatch() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.REPORTING_90D; + decl.holdingPeriodMonths = 6; + decl.currentInfoRequired = true; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // --------------------------------------------------------------------- + // Test 4 continuation / gate ④b — REPORTING_90D + !currentInfoRequired fails + // --------------------------------------------------------------------- + + function test_check_fails_tagConflict_onReportingWithoutCurrentInfo() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.REPORTING_90D; + decl.holdingPeriodMonths = 6; + decl.currentInfoRequired = false; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(5)); + } + + // --------------------------------------------------------------------- + // gate ④b — NON_REPORTING + currentInfoRequired overclaim: hardening, + // must still PASS (doc: routed to a non-blocking REVIEW queue; see the + // contract-level @dev comment on why the ReviewQueued event itself cannot + // be emitted from this `view` check). + // --------------------------------------------------------------------- + + function test_check_passes_onNonReportingWithCurrentInfoOverclaim() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.reportingStatus = TransferRestrictionMetadata.ReportingStatus.NON_REPORTING; + decl.holdingPeriodMonths = 12; + decl.currentInfoRequired = true; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // --------------------------------------------------------------------- + // Test 5 (doc §7.5) / gate ⑤ — unrestrict exit basis: presence AND + // approved-chain membership both required. + // --------------------------------------------------------------------- + + function test_check_fails_unrestrictBasisMissing_whenNoRefAndFrameworkAllowsIt() public { + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.issuanceFramework = REG_A; // requiresRestricted[REG_A] == false, so ② allows flag=false through + decl.restrictedFlag = false; + decl.unrestrictBasisRef = bytes32(0); + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(6)); + } + + function test_check_fails_unrestrictBasisMissing_whenRefPresentButNotApproved() public { + // Presence-only check would wrongly pass this — the ref must also be + // in the approved chain (doc §5.3 row ⑤ regression). + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.issuanceFramework = REG_A; + decl.restrictedFlag = false; + decl.unrestrictBasisRef = BASIS_REF; // never approved + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _reasonCode(6)); + } + + function test_check_passes_onUnrestrict_withApprovedBasis() public { + element.setApprovedUnrestrictBasis(BASIS_REF, true); + + TransferRestrictionMetadata.RestrictionDecl memory decl = _validRestrictedDecl(); + decl.issuanceFramework = REG_A; + decl.restrictedFlag = false; + decl.unrestrictBasisRef = BASIS_REF; + element.setDeclaration(asset, decl); + + (bool passed, bytes32 reasonCode) = element.check(address(0), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } +}