diff --git a/FEATURES.md b/FEATURES.md index 4f86b6b..5ddb863 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -507,3 +507,85 @@ passing 요구하지 않음), production legal 활성화, look-through graph 실제 순회. - Deferred follow-up: 6개 element를 실제 recipe(`requiredElements`)에 연결할지, 연결한다면 어떤 recipe에 붙일지는 별도 feature에서 결정한다. + +## CMP-004 — Wave-2b In-Place Element Upgrades (A-01/A-03/A-04/A-13/B-01/B-02) + CLI Decode + +### Behavior + +- 기존 6개 illustrative mock element — A-01 Sanctions, A-03 AccreditedInvestor, + A-04 IdentityUniqueness, A-13 QualifiedPurchaser, B-01 AssetClassification, + B-02 Erc3643Native — 를 동일 contract 파일·registry ID·recipe 배선을 유지한 + 채 각 element walkthrough 문서의 전체 실패코드 taxonomy로 in-place + 업그레이드했다: A-01 10개, A-03/A-04/A-13 각 9개, B-01/B-02 각 6개 + reasonCode(`ReasonCodes.encode(0, ELEMENT_ID, n)`, n -> doc §6.x 이름 — + 각 contract 헤더 주석 테이블 참고). +- 호환성 규칙(non-negotiable): 기존 operator setter — + `Sanctions.setBlocked`, `AccreditedInvestor.setAccredited`, + `IdentityUniqueness.bindIdentity`, `QualifiedPurchaser.setQp`, + `AssetClassification.setClassification`, `Erc3643Native.setErc3643Native` + — 는 시그니처와 legacy happy-path 효과(그 호출만으로 `check` PASS)를 + 그대로 유지한다: 각 setter가 내부적으로 완전히 유효한 + claim/card(trusted issuer, valid signature, no expiry)를 대신 기록한다. +- 새로 추가된 strictness(claim pipeline, look-through 게이트, 시간/신선도 + 경계, B-02 live-wiring 등)는 전부 operator-gated opt-in이며 기본값 + off(`false`/`0`)다 — 이 config를 건드리지 않는 기존 흐름은 legacy 동작 + 그대로 유지된다. +- `services/cli/src/reason.ts`의 reason-code decode table을 확장했다: + - 업그레이드된 6개 element의 신규 code range를 각 contract 헤더의 + doc-name으로 채운 `ELEMENT_CODE_NAMES` 테이블을 추가(예: A-01 code 4 -> + `FAIL_NO_SANCTIONS_CLAIM`). + - `ELEMENT_LABELS`에서 누락돼 있던 wave-2(CMP-003) 6개 element(A-08 1-8, + A-09 1-2, A-11 1-5, B-03 1-6, B-04 1-7, D-01 1-4)도 등록해 + `DeployStack`이 실제로 등록하는 17개 element 전체와 맞췄다(이전 주석은 + "11개 element"라고 잘못 명시하고 있었음). + - 신규 code가 없는 5개 element(A-02/A-05/C-01/E-01/F-02)는 기존처럼 + code 1 fallback을 유지한다. + - **recipeId 축 추가**: `ComplianceEngine._runChecks`는 실패 시 항상 + `encode(contributingRecipe[i], elementId, 1)`로 code를 1로 재-encode + 하므로(엔진 코드 미변경 범위), recipe-scoped(`{1,2,7}`) 조합에서 실제로 + 도달 가능한 code는 여전히 1뿐이다. 반면 각 element의 `check()`는 항상 + `ReasonCodes.encode(0, ELEMENT_ID, n)`으로 스스로를 self-encode하며(실제 + 상세 코드가 나타나는 지점), D-01 HolderCount의 `onTransfer`는 이 값으로 + 직접 `Errors.ComplianceRejected`를 revert한다. 따라서 recipeId 0 축을 + 별도로 추가해(엔진과 무관하게 element 직접 호출·revert·이벤트에서 실제로 + 관측되는 값) 신규 code들이 실질적으로 디코딩되도록 했다(기존 recipeId + `{1,2,7}` 축도 audit-matching 용도로 유지, code 범위만 확장). + +### Verification + +- `forge build --offline`. +- `forge test --offline`: 39 suites, 579 passed, 0 failed, 0 skipped(레거시 + `test/unit/compliance/Elements.t.sol`의 Sanctions/AccreditedInvestor/ + QualifiedPurchaser 케이스 불변 + 각 element 전용 + `test/unit/compliance/elements/{Sanctions,AccreditedInvestor, + IdentityUniqueness,QualifiedPurchaser,AssetClassification, + Erc3643Native}.t.sol`에 신규 code·경계 테스트 다수 추가 — 개별 U1-U6 + 커밋에서 이미 작성·통과). +- `cd services/cli && npm test`: reason-table 크기 회귀를 + `4 * 86(elements 코드 합) + 6(policy)` = 350으로 갱신하고, `cast keccak`로 + 독립 검증한 신규 ground-truth(A-01 direct code 4, D-01 direct code 3) + decode 회귀 및 A-01 code 1 legacy 의미 보존 회귀를 추가. +- `cd services/rfq && npm test`. +- 수동 확인(코드 변경 없음): `script/DeployStack.s.sol`의 KYC/investor-setup + 헬퍼(`setClassification`, `setErc3643Native`, `bindIdentity`, + `setAccredited`)와 `test/integration/IntegrationBase.sol`의 9-element + attestation 헬퍼(`setAccredited`/`setQp`/`setBlocked`/`bindIdentity`)가 + legacy 시그니처 그대로 호출됨을 확인; `scripts/e2e-anvil.sh`는 CLI를 + 전혀 호출하지 않고 `DeployStack`/`DemoScenarios` forge script만 + 구동하므로 이번 CLI 변경과 무관함을 확인. + +### State + +passing + +### Notes + +- Non-goals: 이번 entry는 U1-U6에서 이미 완료된 6개 element 자체의 스펙 + 변경(claim pipeline, look-through 등, 커밋 `a99f4ae`..`5a36111`)을 다시 + 구현하지 않는다 — CLI decode table 확장과 통합 문서화·전체 검증에 + 한정된다. +- `ComplianceEngine`의 코드-1 하드코딩(재-encode) 자체는 엔진 파일 편집 금지 + 범위 밖이라 이번 wave에서 고치지 않았다 — richer per-element propagation은 + 별도 feature로 이연. +- Deferred follow-up: CMP-003과 동일하게 wave-2 6개 element의 recipe 연결 + 여부는 미결. diff --git a/PROGRESS.md b/PROGRESS.md index 6dc840b..0a9cf8b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -67,6 +67,15 @@ source of truth로 사용한다. A-04/A-03 주소 + `CapMode.TWELVE_G`로 생성 후 engine 생성 이후 `setEngine`). 어떤 recipe의 `requiredElements`에도 연결하지 않음(별도 feature로 이연). `forge test --offline` 399/399 유지. +- `CMP-004 — Wave-2b In-Place Element Upgrades + CLI Decode`(A-01 Sanctions, + A-03 AccreditedInvestor, A-04 IdentityUniqueness, A-13 QualifiedPurchaser, + B-01 AssetClassification, B-02 Erc3643Native를 동일 ID·배선을 유지한 채 + walkthrough-doc 실패코드 taxonomy로 in-place 업그레이드, legacy setter + 호환·신규 strictness는 opt-in default-off; `services/cli/src/reason.ts` + decode table을 신규 code range + 누락됐던 wave-2 6개 element로 확장하고 + recipeId 0(element 직접 self-encode) 축을 추가): + `forge test --offline` 579/579, `services/cli`/`services/rfq` npm test + 통과. ## Blocked @@ -86,32 +95,52 @@ source of truth로 사용한다. 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에 붙일지 결정한다. +8. `ComplianceEngine._runChecks`가 실패한 element의 실제 reasonCode 대신 항상 + code 1로 재-encode하는 한계(CMP-004에서 확인)를 해소할지 결정한다 — richer + per-element propagation을 엔진에 배선하면 CLI/off-chain audit가 recipe-scoped + 경로에서도 신규 실패코드(A-01 code 2-10 등)를 직접 관측할 수 있다. ## Last Session Summary -- `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 작업. +- `CMP-004 — Wave-2b In-Place Element Upgrades + CLI Decode` (U7 integration + task, `feat/element-upgrades` branch): 6개 element(A-01/A-03/A-04/A-13/B-01/ + B-02) 자체의 walkthrough-doc 업그레이드는 이전 세션들(U1-U6, 커밋 + `a99f4ae`..`5a36111`)에서 이미 완료돼 있었고, 이번 세션은 그 위에 CLI decode + table 확장 + 통합 문서화 + 전체 회귀 검증을 수행했다. - 변경한 파일: - - `script/DeployStack.s.sol` + - `services/cli/src/reason.ts` + - `services/cli/test/smoke.ts` - `FEATURES.md`, `PROGRESS.md` - 완료한 작업: - - 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로 이연) + - `reason.ts`에 6개 업그레이드 element의 신규 code range를 각 contract + 헤더 doc-name으로 채운 `ELEMENT_CODE_NAMES` 테이블 추가. + - `ELEMENT_LABELS`에서 누락돼 있던 wave-2(CMP-003) 6개 element(A-08/A-09/ + A-11/B-03/B-04/D-01)를 등록해 `DeployStack`이 실제 등록하는 17개 element + 전체와 일치시킴(이전 주석의 "11개 element"는 stale이었음). + - 코드 추적 결과 `ComplianceEngine._runChecks`가 실패 시 항상 code 1로 + 재-encode함을 확인 — recipe-scoped(`{1,2,7}`) 조합에서 code 2+ 는 오늘 + 실제로 도달 불가능하다. 반면 모든 element의 `check()`는 스스로 + `ReasonCodes.encode(0, ELEMENT_ID, n)`으로 self-encode하고(D-01 + HolderCount는 이 값으로 직접 revert), 이것이 신규 code들이 실제로 관측되는 + 경로다. 그래서 recipeId 0 축(element 직접 코드)을 테이블에 별도로 + 추가했다 — 기존 recipe-scoped 축(code 1만, audit-matching 용도)은 그대로 유지. + - `smoke.ts`의 reason-table 크기 회귀를 `4 * 86 + 6 = 350`으로 갱신하고, + `cast keccak`로 독립 검증한 신규 ground-truth(A-01 direct code 4, D-01 + direct code 3) decode 회귀와 A-01 code 1 legacy 의미 보존 회귀를 추가. + - `script/DeployStack.s.sol`(KYC/investor-setup 헬퍼)과 + `test/integration/IntegrationBase.sol`(9-element attestation 헬퍼)이 + legacy setter 시그니처 그대로 호출됨을 확인 — 코드 변경 없음. + `scripts/e2e-anvil.sh`는 CLI를 전혀 호출하지 않으므로(순수 + `DeployStack`/`DemoScenarios` forge script 구동) 이번 변경과 무관함을 확인. - 실행한 검증: - `forge build --offline` - - `forge test --offline`(전체 399/399 유지; 기존 test 조정 불필요 — - `DeployStack.s.sol`은 forge test 경로에서 실행되지 않음) - - `forge fmt script/DeployStack.s.sol` + - `forge test --offline`: 39 suites, 579 passed, 0 failed, 0 skipped + - `cd services/cli && npm test` + - `cd services/rfq && npm test` + - `forge fmt` 대상 없음(.sol 변경 없음) - 남은 리스크: - - 6개 element는 아직 어떤 recipe에도 연결되지 않아 trade-path 동작이 - 검증되지 않았다(단위 테스트만 존재). - - `DeployStack.s.sol`의 실제 라이브 Anvil 배포/CLI 경로 재실행(`scripts/e2e-anvil.sh`)은 - 이번 세션에서 수행하지 않았다. + - `ComplianceEngine`의 code-1 하드코딩(위 참고)은 이번 세션에서 고치지 + 않았다 — 엔진 파일 편집은 이 wave의 범위 밖. richer per-element + propagation을 원한다면 별도 feature로 분리해야 한다(Next #8). + - CMP-003의 wave-2 6개 element는 여전히 어떤 recipe에도 연결돼 있지 않다 + (Next #7, 변경 없음). diff --git a/services/cli/src/reason.ts b/services/cli/src/reason.ts index 538e8b8..acd2cbc 100644 --- a/services/cli/src/reason.ts +++ b/services/cli/src/reason.ts @@ -1,7 +1,13 @@ import {AbiCoder, encodeBytes32String, keccak256} from "ethers"; -// Element id (bytes32 string) -> human label. These are the 11 elements the -// DeployStack registers; see script/DeployStack.s.sol:_deployAndRegisterElements. +// Element id (bytes32 string) -> human label. All 17 elements DeployStack +// registers; see script/DeployStack.s.sol:_deployAndRegisterElements. The +// first 11 are the original illustrative elements — six of which (A-01, +// A-03, A-04, A-13, B-01, B-02) were upgraded in place to the +// walkthrough-doc failure-code taxonomy (wave-2b, see ELEMENT_CODE_NAMES +// below); the last 6 (A-08, A-09, A-11, B-03, B-04, D-01) are the wave-2 +// illustrative elements (CMP-003) — registered but not yet wired into any +// recipe's `requiredElements`. export const ELEMENT_LABELS: Record = { "A-01-v1": "Sanctions", "A-02-v1": "Jurisdiction", @@ -13,7 +19,13 @@ export const ELEMENT_LABELS: Record = { "C-01-v1": "Lockup (Rule 144)", "E-01-v1": "Form D Filing", "A-13-v1": "Qualified Purchaser", - "F-02-v1": "Surveillance Flag" + "F-02-v1": "Surveillance Flag", + "A-08-v1": "Entity Eligibility", + "A-09-v1": "Equity Owner Look-Through", + "A-11-v1": "Claim Freshness", + "B-03-v1": "Transfer Restriction Metadata", + "B-04-v1": "Engine Selection", + "D-01-v1": "Holder Count" }; // Recipe id -> human label. Recipes registered by DeployStack. @@ -33,6 +45,134 @@ export const POLICY_STATUS: Record = { 5: "RETIRED" }; +// Per-element reason-code name tables: code `n` (in +// `ReasonCodes.encode(recipeId, elementId, n)`) -> the exact doc-name string +// from that element contract's header comment table. Elements NOT listed +// here (A-02, A-05, C-01, E-01, F-02 — still single-code mocks) fall back to +// code 1 -> the element's human label, exactly the file's original behavior +// (see buildTable below). +export const ELEMENT_CODE_NAMES: Record> = { + // A-01-v1 Sanctions (src/compliance/elements/Sanctions.sol header table). + "A-01-v1": { + 1: "FAIL_SDN_WALLET_MATCH", + 2: "FAIL_SDN_IDENTITY_MATCH", + 3: "FAIL_50PCT_RULE", + 4: "FAIL_NO_SANCTIONS_CLAIM", + 5: "FAIL_UNTRUSTED_SANCTIONS_ISSUER", + 6: "FAIL_INVALID_SANCTIONS_SIGNATURE", + 7: "FAIL_SANCTIONS_CLAIM_EXPIRED", + 8: "FAIL_SANCTIONS_CLAIM_STALE_LIST", + 9: "FAIL_50PCT_LOOKTHROUGH_PENDING", + 10: "REVIEW_SANCTIONS_UNCERTAIN" + }, + // A-03-v1 AccreditedInvestor. + "A-03-v1": { + 1: "NO_AI_CLAIM", + 2: "UNTRUSTED_AI_CLAIM_ISSUER", + 3: "INVALID_AI_CLAIM_SIGNATURE", + 4: "AI_CLAIM_EXPIRED", + 5: "506C_VERIFICATION_NOT_ESTABLISHED", + 6: "4A7_PURCHASER_NOT_AI", + 7: "AI_LOOKTHROUGH_PENDING", + 8: "AI_CATEGORY_UNSUPPORTED", + 9: "REVIEW_AI_UNCERTAIN" + }, + // A-04-v1 IdentityUniqueness. + "A-04-v1": { + 1: "IDENTITY_NOT_REGISTERED", + 2: "KYC_CLAIM_MISSING", + 3: "KYC_CLAIM_INVALID_SIG", + 4: "UNTRUSTED_KYC_ISSUER", + 5: "KYC_CLAIM_EXPIRED", + 6: "IDENTITY_FROZEN", + 7: "IDENTITY_REVOKED", + 8: "DUPLICATE_IDENTITY", + 9: "REVIEW_IDENTITY_DUPLICATE_SUSPECTED" + }, + // A-13-v1 QualifiedPurchaser. + "A-13-v1": { + 1: "FAIL_NOT_QP", + 2: "FAIL_QP_CLAIM_EXPIRED", + 3: "FAIL_UNTRUSTED_QP_CLAIM_ISSUER", + 4: "FAIL_QP_LOOKTHROUGH_REQUIRED", + 5: "FAIL_QP_LOOKTHROUGH_NOT_COMPLETED", + 6: "FAIL_TRUST_DISQUALIFIED", + 7: "FAIL_FAMILY_CO_NOT_QP", + 8: "FAIL_KNOWLEDGEABLE_EMP_NOT_QUALIFIED", + 9: "REVIEW_QP_UNCERTAIN" + }, + // B-01-v1 AssetClassification. + "B-01-v1": { + 1: "MANIFEST_MISSING", + 2: "MANIFEST_SUSPENDED", + 3: "VERSION_UNAPPROVED", + 4: "VERSION_PENDING", + 5: "FACTS_INCONSISTENT", + 6: "FACT_STALE" + }, + // B-02-v1 Erc3643Native. + "B-02-v1": { + 1: "TOKEN_STANDARD_MISMATCH", + 2: "TOKEN_WIRING_DRIFT", + 3: "TOKEN_PAUSED", + 4: "TOKEN_FROZEN_PARTY", + 5: "TOKEN_INSUFFICIENT_UNFROZEN", + 6: "TOKEN_TRANSFER_INELIGIBLE" + }, + // A-08-v1 EntityEligibility (wave-2, CMP-003). + "A-08-v1": { + 1: "ENTITY_CATEGORY_MISMATCH", + 2: "ENTITY_THRESHOLD_NOT_MET", + 3: "ENTITY_QIB_UNCONFIRMED", + 4: "ENTITY_FORMED_FOR_PURPOSE", + 5: "ENTITY_LOOKTHROUGH_REQUIRED", + 6: "ENTITY_LOOKTHROUGH_FAILED", + 7: "ENTITY_DIRECT_REQ_MISSING", + 8: "ENTITY_AND_GATE_FAIL" + }, + // A-09-v1 EquityOwnerLookThrough (wave-2). The contract collapses the doc + // §6.2 REVIEW_*/FAIL_* families into two on-chain outcomes; one + // representative doc-name each (see EquityOwnerLookThrough.sol header). + "A-09-v1": { + 1: "REVIEW_OWNERSHIP_GRAPH_INCOMPLETE", // + REVIEW_LOOKTHROUGH_DEPTH_EXCEEDED, REVIEW_FAMILY_OWNERSHIP_ATTRIBUTION, REVIEW_AI_LOOKTHROUGH_PENDING, REVIEW_TRUST_QP_IV_INDEPENDENT, PARTIAL_REVIEW + 2: "FAIL_LOOKTHROUGH_OWNER_NOT_QUALIFIED" // + FAIL_AI_OWNER_NOT_ACCREDITED, FAIL_FORMED_FOR_SPECIFIC_PURPOSE_NON_QP, FAIL_FAMILY_COMPOSITION_NOT_MET + }, + // A-11-v1 ClaimFreshness (wave-2). + "A-11-v1": { + 1: "FAIL_NO_VERIFIED_AT", + 2: "FAIL_CLAIM_STALE_AI", + 3: "FAIL_CLAIM_STALE_QP", + 4: "FAIL_CLAIM_EXPIRED", + 5: "FAIL_UNKNOWN_CLAIM_TYPE" + }, + // B-03-v1 TransferRestrictionMetadata (wave-2). + "B-03-v1": { + 1: "RESTRICTION_DECL_MISSING", + 2: "RESTRICTION_STATUS_CONFLICT", + 3: "RESTRICTION_TAGS_INCOMPLETE", + 4: "RESTRICTION_TAG_INVALID", + 5: "RESTRICTION_TAG_CONFLICT", + 6: "UNRESTRICT_BASIS_MISSING" + }, + // B-04-v1 EngineSelection (wave-2). + "B-04-v1": { + 1: "FAIL_ENGINE_DECL_MISSING", + 2: "FAIL_ENGINE_DECL_INVALID", + 3: "FAIL_ENGINE_UNKNOWN", + 4: "FAIL_ENGINE_NOT_SUPPORTED", + 5: "FAIL_ENGINE_PATH_INCOMPATIBLE", + 6: "FAIL_ENGINE_AFFILIATE_INCOMPATIBLE", + 7: "FAIL_ENGINE_MM_CLAIM_MISSING" + }, + // D-01-v1 HolderCount (wave-2, STATEFUL). + "D-01-v1": { + 1: "HOLDER_CAP_12G_TOTAL", + 2: "HOLDER_CAP_12G_NONAI", + 3: "HOLDER_CAP_3C1_100", + 4: "HOLDER_CAP_506B_35" + } +}; + const coder = AbiCoder.defaultAbiCoder(); // ComplianceEngine._runChecks / ReasonCodes.encode: @@ -50,22 +190,65 @@ interface TableEntry { label: string; } -// Precompute every known reason code: (recipeId in {1,2,7}) x (11 elementIds) x -// (code 1), plus the engine's policy-status rejections -// (ComplianceEngine._rejectPolicy: encode(0, "POLICY", uint32(status))). +// Precompute every known reason code from THREE sources: +// +// 1. Engine-propagated verdicts: (recipeId in {1,2,7}) x (17 elementIds) x +// (every code in that element's ELEMENT_CODE_NAMES table, or just code 1 +// for elements without one). `ComplianceEngine._runChecks` currently +// re-encodes every per-element failure as `encode(contributingRecipe, +// elementId, 1)` (code hardcoded to 1 regardless of the element's actual +// failure), so code 1 is the only one of these actually reachable via +// `evaluate()`/`ExecutionRouter`'s `ComplianceRejected` today — codes 2+ +// are precomputed anyway (matching this file's existing precedent of +// covering known combos ahead of use, per the audit-matching rationale in +// ReasonCodes.sol) so decoding stays correct if/when richer propagation +// lands. +// 2. Direct element-level codes: (recipeId 0) x (17 elementIds) x (every +// code in ELEMENT_CODE_NAMES, or code 1 for elements without one). Every +// element's own `check()` self-encodes with `ReasonCodes.encode(0, +// ELEMENT_ID, n)` (see e.g. Sanctions.sol, HolderCount.sol) — this is +// the reasonCode actually returned by calling an element directly, by +// stateful elements' own `ComplianceRejected` reverts (e.g. D-01 +// HolderCount.onTransfer), and by monitoring-flag events (e.g. +// SurveillanceFlag). THIS is where the wave-2b codes 2-10 are genuinely +// decodable today. +// 3. The engine's policy-status rejections (recipeId 0, sentinel element +// "POLICY"): `ComplianceEngine._rejectPolicy`: encode(0, "POLICY", uint32(status)). function buildTable(): Map { const table = new Map(); - const recipeIds = [1, 2, 7]; - for (const recipeId of recipeIds) { + + function addElementEntries(recipeId: number, label: (elementId: string, codeNum: number, name: string) => string) { for (const elementId of Object.keys(ELEMENT_LABELS)) { - const code = encodeReason(recipeId, elementId, 1); - const recipeLabel = RECIPE_LABELS[recipeId] ?? `recipe ${recipeId}`; - table.set(code, { - label: `recipe ${recipeId} (${recipeLabel}) / ${elementId} / code 1 -> ${ELEMENT_LABELS[elementId]}` - }); + const codeNames = ELEMENT_CODE_NAMES[elementId]; + if (codeNames) { + for (const codeStr of Object.keys(codeNames)) { + const codeNum = Number(codeStr); + const code = encodeReason(recipeId, elementId, codeNum); + table.set(code, {label: label(elementId, codeNum, codeNames[codeNum])}); + } + } else { + const code = encodeReason(recipeId, elementId, 1); + table.set(code, {label: label(elementId, 1, ELEMENT_LABELS[elementId])}); + } } } - // Policy-status rejections carry recipeId 0 and the sentinel element "POLICY". + + // 1. Engine-propagated verdicts (recipe-scoped). + for (const recipeId of [1, 2, 7]) { + const recipeLabel = RECIPE_LABELS[recipeId] ?? `recipe ${recipeId}`; + addElementEntries( + recipeId, + (elementId, codeNum, name) => `recipe ${recipeId} (${recipeLabel}) / ${elementId} / code ${codeNum} -> ${name}` + ); + } + + // 2. Direct element-level codes (recipeId 0, real element ids). + addElementEntries( + 0, + (elementId, codeNum, name) => `element check (recipeId 0) / ${elementId} / code ${codeNum} -> ${name}` + ); + + // 3. Policy-status rejections carry recipeId 0 and the sentinel element "POLICY". for (const status of Object.keys(POLICY_STATUS).map(Number)) { const code = encodeReason(0, "POLICY", status); table.set(code, { diff --git a/services/cli/test/smoke.ts b/services/cli/test/smoke.ts index 8c988f2..7f457ee 100644 --- a/services/cli/test/smoke.ts +++ b/services/cli/test/smoke.ts @@ -33,15 +33,33 @@ function recoverMaker(quote: any, signature: string): {recovered: string; ok: bo const A02_RECIPE1 = "0xdf005707ef0d1c9c5675600e45928090b03a3d8ea92af2d691132565e834e7c0"; const POLICY_SUSPENDED = "0x6c918c291dab5574048c8f619004a9721b8ac1b978c93e69f239e614a34d5e4f"; const A01_RECIPE7 = "0x4ec564787cbeb03d100cec07278646352648e18a22c4b6e3a8549fa92f376f46"; +// Wave-2b: direct element-level codes (recipeId 0), the reasonCode an +// element's own `check()` actually self-encodes (e.g. Sanctions.sol, +// HolderCount.sol) — ground truth via `cast keccak $(cast abi-encode +// "f(uint16,bytes32,uint32)" 0 $(cast format-bytes32-string "") )`. +const A01_DIRECT_CODE4 = "0x8bb6a77feb777933299995cf60c2f3d5a4804be0f6077feab1f7390c76179f9c"; +const D01_DIRECT_CODE3 = "0x944f96138687357570c74d60495e55251602230e456018945bdf8e15bc1241ba"; async function main() { // --- reason table ------------------------------------------------------- assert(encodeReason(1, "A-02-v1", 1) === A02_RECIPE1, "encode matches cast (A-02 recipe 1)"); assert(encodeReason(0, "POLICY", 3) === POLICY_SUSPENDED, "encode matches cast (POLICY suspended)"); assert(encodeReason(7, "A-01-v1", 1) === A01_RECIPE7, "encode matches cast (A-01 recipe 7)"); - - // table = 3 recipes x 11 elements + 6 policy statuses. - assert(tableSize() === 3 * 11 + 6, "reason table size"); + assert(encodeReason(0, "A-01-v1", 4) === A01_DIRECT_CODE4, "encode matches cast (A-01 direct code 4)"); + assert(encodeReason(0, "D-01-v1", 3) === D01_DIRECT_CODE3, "encode matches cast (D-01 direct code 3)"); + + // table = (recipe-scoped: 3 recipes x codes-per-element-sum) + (direct + // element-level: 1 x codes-per-element-sum, recipeId 0 — the reasonCode an + // element's own `check()` actually self-encodes) + 6 policy statuses. + // codes-per-element-sum is each of the 17 elements' code count, where an + // element without a richer ELEMENT_CODE_NAMES table contributes 1. + // Wave-2b upgraded 6 elements to multi-code taxonomies (A-01:10, A-03:9, + // A-04:9, A-13:9, B-01:6, B-02:6) and the wave-2 illustrative elements + // (A-08:8, A-09:2, A-11:5, B-03:6, B-04:7, D-01:4) are also enumerated; + // the remaining 5 single-code mocks (A-02, A-05, C-01, E-01, F-02) + // contribute 1 each. + const CODES_PER_ELEMENT = 10 + 1 + 9 + 9 + 1 + 6 + 6 + 1 + 1 + 9 + 1 + 8 + 2 + 5 + 6 + 7 + 4; // = 86 + assert(tableSize() === 4 * CODES_PER_ELEMENT + 6, "reason table size"); const jur = decodeReason(A02_RECIPE1); assert(jur.label.includes("Jurisdiction") && jur.label.includes("A-02-v1"), "decodes A-02 to Jurisdiction"); @@ -51,6 +69,19 @@ async function main() { assert(decodeReason(A02_RECIPE1.toUpperCase()).label === jur.label, "upper-case input decodes"); assert(decodeReason("0x" + "de".repeat(32)).label === "unknown code", "unknown code"); + // Wave-2b: direct element-level (recipeId 0) codes decode to the doc-name + // from that element's header table — this is the reasonCode actually + // returned by an element's own `check()` / thrown via `ComplianceRejected` + // (e.g. D-01 HolderCount.onTransfer), unlike the engine-propagated verdict + // which today always carries code 1. + const sanctionsClaim = decodeReason(A01_DIRECT_CODE4); + assert(sanctionsClaim.label.includes("FAIL_NO_SANCTIONS_CLAIM"), "decodes A-01 direct code 4"); + const holderCap = decodeReason(D01_DIRECT_CODE3); + assert(holderCap.label.includes("HOLDER_CAP_3C1_100"), "decodes D-01 direct code 3"); + // Legacy code-1 meaning is preserved across the wave-2b upgrade (doc says + // code 1 keeps the pre-upgrade "blocked wallet" semantics for A-01). + assert(decodeReason(encodeReason(0, "A-01-v1", 1)).label.includes("FAIL_SDN_WALLET_MATCH"), "A-01 code 1 preserved"); + // --- quote-file round-trip --------------------------------------------- const maker = new Wallet("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); const service = new RFQQuoteService( diff --git a/src/compliance/elements/AccreditedInvestor.sol b/src/compliance/elements/AccreditedInvestor.sol index a5a4334..6c638e2 100644 --- a/src/compliance/elements/AccreditedInvestor.sol +++ b/src/compliance/elements/AccreditedInvestor.sol @@ -12,16 +12,137 @@ import { Statefulness } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {LookThroughStatus} from "../../interfaces/compliance/ILookThroughSource.sol"; -/// @dev A-03-v1 Accredited investor attestation (mock). Settable per-user flag -/// stands in for a real claim (existence + issuer + expiry, Pattern B). +/// @dev A-03-v1 Accredited Investor (mock), upgraded to the doc §5.2 claim +/// pipeline. Stands in for the Trusted-Issuer-signed `ACCREDITED_INVESTOR` +/// claim (doc §4) that Rule 506(c) issuance and §4(a)(7) resale both +/// require of the buyer: A-03 never re-derives income/net-worth/entity +/// facts on-chain (doc §3.3 "읽는 법"), it only confirms the claim's +/// existence, issuer trust, signature, expiry and verification basis. +/// +/// Legacy compatibility (non-negotiable, see wave-2b plan header): +/// `setAccredited(address,bool)` keeps its exact signature and its +/// legacy happy-path effect — `setAccredited(user, true)` alone writes a +/// fully-valid claim (exists, trusted, valid sig, no expiry, +/// verificationBasisAccepted, basis = DIRECT) that PASSes `check` with no +/// further calls; `false` clears it. `accredited(address)` is kept as +/// its OWN storage bool (not derived from the claim at read time) so +/// HolderCount (D-01) and integration helpers keep a truthful coarse +/// view — both `setAccredited` and `setClaim` maintain it in lockstep +/// (`setClaim` sets it to `claim.exists`). New strictness (per-asset +/// `requires506cVerification` / `sec4a7PathOf`) is operator-set and +/// defaults OFF, so no currently-passing flow starts failing. +/// +/// Reason code map — `n` in `ReasonCodes.encode(0, ELEMENT_ID, n)` -> doc +/// §6 failure-code name, in `check()` evaluation order: +/// 1 | NO_AI_CLAIM claim.exists == false (legacy +/// "not accredited" fail — same +/// meaning, compatible) +/// 2 | UNTRUSTED_AI_CLAIM_ISSUER claim.issuerTrusted == false +/// 3 | INVALID_AI_CLAIM_SIGNATURE claim.signatureValid == false +/// 4 | AI_CLAIM_EXPIRED claim.expiry set and passed +/// (STRICT `>` — exactly-at-expiry +/// PASSes, doc §5.2/§7 boundary) +/// 5 | 506C_VERIFICATION_NOT_ESTABLISHED asset requires 506(c) +/// verification and the claim +/// lacks `verificationBasisAccepted` +/// 6 | 4A7_PURCHASER_NOT_AI NARROW, doc-ambiguity resolution +/// (see below) — asset is on the +/// §4(a)(7) resale path and the +/// claim exists+valid but carries +/// no AI category (`basis == NONE`) +/// 7 | AI_LOOKTHROUGH_PENDING basis == ALL_EQUITY_OWNERS and +/// `ltStatus != COMPLETED` — doc +/// ambiguity resolution: the doc's +/// table has only +/// FAIL_AI_LOOKTHROUGH_PENDING, no +/// separate "look-through FAILED" +/// code, so NONE/PENDING/FAILED all +/// map to code 7 here (comment on +/// `AiClaim.ltStatus` below) +/// 8 | AI_CATEGORY_UNSUPPORTED basis == OTHER +/// 9 | REVIEW_AI_UNCERTAIN claim.reviewRequired == true +/// +/// Doc-ambiguity resolution for code 6 (§4(a)(7) purchaser-AI): the doc's +/// own §7 test table maps "§4(a)(7) resale에서 buyer AI claim 없음" to +/// FAIL_NO_AI_CLAIM (code 1), not code 6 — because our claims are +/// attest-only, a claim that exists and is otherwise valid already IS an +/// AI determination, so there is no natural "claim says AI but really +/// isn't" state to distinguish with code 6. Rather than leave code 6 +/// unreachable, it is scoped narrowly to a claim "shell" that exists and +/// is otherwise valid but was never assigned an AI category +/// (`basis == NONE`) on an asset flagged as a §4(a)(7) resale +/// (`sec4a7PathOf[asset]`); a fully missing claim still fails 1 as the +/// doc table requires. contract AccreditedInvestor is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-03-v1"; + /// @dev Rule 501(a) categories collapsed to the granularity A-03 actually + /// branches on (doc §3.13 lists 13 sub-categories under §3.3; all but + /// the look-through category (a)(8) resolve identically here, so they + /// fold into DIRECT). OTHER is the fail-closed bucket for a category + /// this mock does not support (doc §6, AI_CATEGORY_UNSUPPORTED). + enum AiClaimBasis { + NONE, + DIRECT, + ALL_EQUITY_OWNERS, + OTHER + } + + struct AiClaim { + bool exists; + bool issuerTrusted; + bool signatureValid; + uint64 expiry; // 0 = none + bool verificationBasisAccepted; // Rule 506(c)(2)(ii) reasonable-steps basis recorded + AiClaimBasis basis; + LookThroughStatus ltStatus; // meaningful only when basis == ALL_EQUITY_OWNERS ((a)(8)) + bool reviewRequired; // operator/issuer flags manual review (doc REVIEW_AI_UNCERTAIN) + } + + /// @notice buyer => attested AI claim. + mapping(address => AiClaim) public claimOf; + + /// @notice Coarse legacy view of claim existence. NOT derived from `claimOf` + /// at read time (that would be circular) — it is its own storage + /// bool that `setAccredited` and `setClaim` both keep in sync with + /// `claim.exists`. Consumers that only need "has an AI claim been + /// recorded at all" (HolderCount / D-01, integration helpers) read + /// this; `check` uses the full `claimOf` pipeline instead. mapping(address => bool) public accredited; + /// @notice asset => Rule 506(c) verification-basis enforcement, operator-set, + /// default false (opt-in; wave-2b compatibility rule — a legacy + /// `setAccredited(user, true)` claim already carries + /// `verificationBasisAccepted = true`, so turning this on does not + /// break existing flows, it only bites unattested/self-certified claims). + mapping(address => bool) public requires506cVerification; + + /// @notice asset => this asset's active resale path is Securities Act + /// §4(a)(7) (doc §3.11), operator-set, default false. Only used to + /// narrow code 6 (see contract-level NatSpec); Rule 144 resale + /// (buyer-AI not required, doc §3.11/§3.12) leaves this false and + /// A-03 dormant for that leg, same as before this upgrade. + mapping(address => bool) public sec4a7PathOf; + event AccreditedInvestorSet(address indexed investor, bool isAccredited); + event AiClaimSet( + address indexed investor, + bool exists, + bool issuerTrusted, + bool signatureValid, + uint64 expiry, + bool verificationBasisAccepted, + AiClaimBasis basis, + LookThroughStatus ltStatus, + bool reviewRequired + ); + + event Asset506cVerificationRequirementSet(address indexed asset, bool required); + event AssetSec4a7PathSet(address indexed asset, bool enabled); + constructor() BaseElement(ElementMetadata({ elementId: ELEMENT_ID, @@ -34,18 +155,108 @@ contract AccreditedInvestor is BaseElement, Governed { })) {} + /// @notice Legacy setter, signature and happy-path effect preserved + /// exactly. `true` writes a fully-valid claim (exists, trusted, + /// valid sig, no expiry, verification basis accepted, basis + /// DIRECT) that alone PASSes `check`; `false` clears the claim + /// and the coarse `accredited` view. function setAccredited(address user, bool isAccredited) external onlyOperator { accredited[user] = isAccredited; + if (isAccredited) { + claimOf[user] = AiClaim({ + exists: true, + issuerTrusted: true, + signatureValid: true, + expiry: 0, + verificationBasisAccepted: true, + basis: AiClaimBasis.DIRECT, + ltStatus: LookThroughStatus.NONE, + reviewRequired: false + }); + } else { + delete claimOf[user]; + } emit AccreditedInvestorSet(user, isAccredited); } - function check(address user, address, address, uint256, bytes calldata) + /// @notice Writes the full operator-attested AI claim for `user`, standing + /// in for a Trusted-Issuer-signed claim. Keeps the legacy + /// `accredited` view in sync (`accredited[user] = claim.exists`). + function setClaim(address user, AiClaim calldata claim) external onlyOperator { + claimOf[user] = claim; + accredited[user] = claim.exists; + emit AiClaimSet( + user, + claim.exists, + claim.issuerTrusted, + claim.signatureValid, + claim.expiry, + claim.verificationBasisAccepted, + claim.basis, + claim.ltStatus, + claim.reviewRequired + ); + } + + /// @notice Operator toggle: require Rule 506(c) verification basis on `asset`. + /// Default false (opt-in) per the wave-2b compatibility rule. + function setRequires506cVerification(address asset, bool required) external onlyOperator { + requires506cVerification[asset] = required; + emit Asset506cVerificationRequirementSet(asset, required); + } + + /// @notice Operator toggle: mark `asset`'s active resale path as §4(a)(7). + /// Default false (opt-in). See contract-level NatSpec for how this + /// narrowly scopes code 6. + function setSec4a7Path(address asset, bool enabled) external onlyOperator { + sec4a7PathOf[asset] = enabled; + emit AssetSec4a7PathSet(asset, enabled); + } + + /// @dev doc §5.2 order: exists(1) -> issuer(2) -> signature(3) -> + /// expiry(4, STRICT `>`) -> [asset requires 506(c)] verification + /// basis(5) -> [asset is §4(a)(7) path] narrow no-category shell(6) + /// -> category OTHER(8) -> ALL_EQUITY_OWNERS look-through not + /// COMPLETED(7) -> reviewRequired(9) -> PASS. + function check(address user, address, address asset, uint256, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = accredited[user]; - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + AiClaim memory c = claimOf[user]; + + if (!c.exists) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + if (!c.issuerTrusted) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } + if (!c.signatureValid) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + if (c.expiry != 0 && block.timestamp > c.expiry) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + if (requires506cVerification[asset] && !c.verificationBasisAccepted) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + if (sec4a7PathOf[asset] && c.basis == AiClaimBasis.NONE) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 6)); + } + if (c.basis == AiClaimBasis.OTHER) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 8)); + } + if (c.basis == AiClaimBasis.ALL_EQUITY_OWNERS && c.ltStatus != LookThroughStatus.COMPLETED) { + // NONE/PENDING/FAILED all map to code 7 — see contract-level + // NatSpec doc-ambiguity resolution (the doc has no separate + // look-through-FAILED code for A-03). + return (false, ReasonCodes.encode(0, ELEMENT_ID, 7)); + } + if (c.reviewRequired) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 9)); + } + + return (true, bytes32(0)); } } diff --git a/src/compliance/elements/AssetClassification.sol b/src/compliance/elements/AssetClassification.sol index ddeabfb..2368ad4 100644 --- a/src/compliance/elements/AssetClassification.sol +++ b/src/compliance/elements/AssetClassification.sol @@ -13,11 +13,63 @@ import { } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; -/// @dev B-01-v1 Asset classification declaration (mock). Stands in for an -/// issuer/operator declaration that an asset is offered under a given -/// regulation path (e.g. Reg D) — a settable per-asset tag stands in for -/// a real EDGAR/offering-document lookup. ASSET-side check: it inspects -/// `asset`, not `user` (contrast with the investor-attribute elements). +/// @dev B-01-v1 Manifest Integrity (mock), upgraded to the doc §5.2–§5.4 +/// "asset card integrity" spec. B-01 is not a per-requirement gate: it +/// guards the common precondition every other element's decision stands +/// on — that the asset's manifest (신상카드) is present, approved, free of +/// internal contradiction, and fresh (doc §1.2–§1.4). The legacy +/// classification match is preserved here as invariant INV-C +/// (classification must equal `requiredClassification`, doc §3.4/§5.2 ④). +/// +/// Legacy compatibility (non-negotiable, see wave-2b plan header): +/// `setClassification(address,bytes32)` keeps its exact signature and its +/// legacy happy-path effect — it alone writes a fully-live card (ACTIVE, +/// version 1 approved as version 1, approvedAt = now with the default +/// zero activation delay, factsAsOf = now, maxFactAge 0 = freshness +/// disabled) that PASSes `check` with no further calls. The +/// `requiredClassification` immutable and its constructor zero-guard are +/// kept as-is, and `classificationOf(address)` is preserved as a view over +/// the card's `classification` field so callers that read the old public +/// mapping getter (integration helpers, CLI) keep compiling. New +/// strictness (suspension, version approval, time-lock, freshness) is +/// operator-set through `setCard`/`setActivationDelay` and defaults to the +/// fully-live/off state, so no currently-passing flow starts failing. +/// +/// Mock boundary: production B-01 reads the REAL manifest registry +/// (TokenPolicyRegistry / ManifestCore) plus an off-chain hash-reconciliation +/// watcher (doc §5.1 channel 2, §3.7 17a-4(f) audit-trail). This mock keeps +/// its own per-asset card mirror instead, because the compliance engine +/// already enforces PolicyStatus at the manifest layer; the card here +/// stands in for the on-chain ManifestCore struct the watcher feeds. +/// REVIEW_MANIFEST_DRIFT (doc §6.1) is the watcher's soft-drift channel — +/// an off-chain operations queue, not a per-tx check code — so it is not +/// emitted here. The doc's `emit B01Check` PASS event is likewise +/// impossible from a `view` check and is a documented omission (wave-2 +/// precedent). +/// +/// Reason code map — `n` in `ReasonCodes.encode(0, ELEMENT_ID, n)` -> doc +/// §6.1 failure-code name, in `check()` evaluation order: +/// 1 | MANIFEST_MISSING card.status == NONE (unattested asset — +/// legacy code-1 "missing/unclassified" +/// meaning preserved) +/// 2 | MANIFEST_SUSPENDED card.status == SUSPENDED (watcher hash +/// mismatch / emergency halt / retire all +/// converge on this one gate, doc §5.2 ②) +/// 3 | VERSION_UNAPPROVED coreVersion != approvedVersion (references +/// a version outside the approved set — a +/// governance-bypass signal, doc §5.2 ③a) +/// 4 | VERSION_PENDING time-lock not elapsed: now < approvedAt + +/// activationDelay (INCLUSIVE at the +/// boundary — now == approvedAt + delay +/// PASSes, doc §5.3) +/// 5 | FACTS_INCONSISTENT INV-C: classification != requiredClassification +/// (a previously code-1 "attested wrong +/// class" case now surfaces here, doc §5.2 ④) +/// 6 | FACT_STALE freshness: maxFactAge != 0 and +/// now - factsAsOf > maxFactAge (STRICT `>`, +/// the OPPOSITE direction to the time-lock — +/// doc §5.3 pins this asymmetry; maxFactAge +/// 0 disables the check) contract AssetClassification is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "B-01-v1"; @@ -26,14 +78,54 @@ contract AssetClassification is BaseElement, Governed { /// (default bytes32(0)) indistinguishable from a correctly declared one. error ZeroRequiredClassification(); + /// @notice Manifest lifecycle state. NONE = no card (missing, code 1); + /// SUSPENDED = watcher/emergency halt (code 2). Default is NONE. + enum CardStatus { + NONE, + ACTIVE, + SUSPENDED + } + + /// @notice The mock ManifestCore mirror for one asset (doc §4.1). Value + /// types only, so the auto-generated `cardOf` getter is usable. + struct AssetCard { + CardStatus status; + bytes32 classification; // legacy field — INV-C: must equal requiredClassification (else code 5) + uint32 coreVersion; // version the card currently references + uint32 approvedVersion; // approved-set membership; coreVersion mismatch => code 3 + uint64 approvedAt; // time-lock anchor: effective at approvedAt + activationDelay + uint64 factsAsOf; // freshness anchor for the expiring facts the card carries + uint64 maxFactAge; // 0 = freshness disabled; now - factsAsOf > maxFactAge => code 6 + } + /// @notice The classification tag every asset must carry to pass `check`. bytes32 public immutable requiredClassification; - /// @notice asset => declared classification tag (bytes32(0) = unclassified). - mapping(address => bytes32) public classificationOf; + /// @notice asset => manifest-integrity card (default: status NONE = missing). + mapping(address => AssetCard) public cardOf; + + /// @notice Governance time-lock delay applied to every card's approvedAt + /// (doc §5.2 ③b / §5.3). It lives OUTSIDE the card on purpose — + /// self-reference guard (doc §3.20): a card must not set the yardstick + /// it is measured against. Operator-set, default 0 (no delay), so a + /// legacy `setClassification` card is effective immediately. + uint64 public activationDelay; event ClassificationSet(address indexed asset, bytes32 classification); + event CardSet( + address indexed asset, + CardStatus status, + bytes32 classification, + uint32 coreVersion, + uint32 approvedVersion, + uint64 approvedAt, + uint64 factsAsOf, + uint64 maxFactAge + ); + + event ActivationDelaySet(uint64 delay); + constructor(bytes32 requiredClassification_) BaseElement(ElementMetadata({ elementId: ELEMENT_ID, @@ -51,22 +143,99 @@ contract AssetClassification is BaseElement, Governed { requiredClassification = requiredClassification_; } - /// @notice Declares (or clears) the classification tag for `asset`. + /// @notice Legacy setter, signature and happy-path effect preserved exactly. + /// Writes a fully-live card for `asset`: ACTIVE, `classification` as + /// given, version 1 approved as version 1, approvedAt = now (so with + /// the default zero `activationDelay` it is effective immediately), + /// factsAsOf = now, maxFactAge 0 (freshness disabled). A live card + /// with the matching classification alone PASSes `check`. function setClassification(address asset, bytes32 classification) external onlyOperator { - classificationOf[asset] = classification; + cardOf[asset] = AssetCard({ + status: CardStatus.ACTIVE, + classification: classification, + coreVersion: 1, + approvedVersion: 1, + approvedAt: uint64(block.timestamp), + factsAsOf: uint64(block.timestamp), + maxFactAge: 0 + }); emit ClassificationSet(asset, classification); } - /// @dev Passes iff `asset`'s declared classification matches - /// `requiredClassification`. `user` is ignored — this is an - /// asset-side, not investor-side, check. + /// @notice Writes the full operator-attested manifest card for `asset`, + /// standing in for the on-chain ManifestCore the watcher maintains. + function setCard(address asset, AssetCard calldata card) external onlyOperator { + cardOf[asset] = card; + emit CardSet( + asset, + card.status, + card.classification, + card.coreVersion, + card.approvedVersion, + card.approvedAt, + card.factsAsOf, + card.maxFactAge + ); + } + + /// @notice Sets the governance activation delay (time-lock) applied to all + /// cards. Default 0. Kept outside the card per the self-reference + /// guard (doc §3.20). + function setActivationDelay(uint64 delay) external onlyOperator { + activationDelay = delay; + emit ActivationDelaySet(delay); + } + + /// @notice Legacy view: the declared classification tag of `asset`, + /// preserved as a view over the card's `classification` field so + /// callers of the old public mapping getter keep working. + function classificationOf(address asset) external view returns (bytes32) { + return cardOf[asset].classification; + } + + /// @dev doc §5.4 order (all deterministic, no external calls): status + /// NONE(1) -> SUSPENDED(2) -> version mismatch(3) -> time-lock + /// pending(4, `now >= approvedAt + delay` PASSes AT the boundary) -> + /// INV-C classification mismatch(5) -> freshness(6, STRICT `>`, opposite + /// boundary direction to the time-lock). `user`/`counterparty`/`amount` + /// are ignored — this is an asset-side, not investor-side, check. function check(address, address, address asset, uint256, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = classificationOf[asset] == requiredClassification; - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + AssetCard memory card = cardOf[asset]; + + if (card.status == CardStatus.NONE) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + if (card.status == CardStatus.SUSPENDED) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } + if (card.coreVersion != card.approvedVersion) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + // Time-lock is INCLUSIVE at the boundary: now == approvedAt + delay is + // effective. `now < ...` is the only pending window (doc §5.3). + if (block.timestamp < uint256(card.approvedAt) + uint256(activationDelay)) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + // INV-C: the classification the card carries must equal the required tag. + if (card.classification != requiredClassification) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + // Freshness is STRICT `>`: now - factsAsOf == maxFactAge still PASSes; + // exceeding it by one second fails. Opposite direction to the time-lock + // above — doc §5.3 pins the asymmetry. maxFactAge 0 disables the check. + // Age is 0 for a future/equal anchor, avoiding underflow. + if ( + card.maxFactAge != 0 && block.timestamp > card.factsAsOf + && block.timestamp - uint256(card.factsAsOf) > uint256(card.maxFactAge) + ) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 6)); + } + + return (true, bytes32(0)); } } diff --git a/src/compliance/elements/Erc3643Native.sol b/src/compliance/elements/Erc3643Native.sol index e3ee364..c94419b 100644 --- a/src/compliance/elements/Erc3643Native.sol +++ b/src/compliance/elements/Erc3643Native.sol @@ -13,22 +13,90 @@ import { } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; -/// @dev B-02-v1 ERC-3643 (T-REX) native asset attestation (mock, ASSET-side). -/// Internal architecture decision: regulated assets must be ERC-3643 -/// (T-REX) native tokens. Production seam: this attestation would be -/// replaced by ERC-165 introspection against the T-REX `IToken` -/// interface ID, or a lookup in an on-chain token registry. An operator -/// attestation stands in here because the vendored T-REX token's -/// ERC-165 support is not guaranteed, and a false negative from a live -/// introspection check would brick settlement for a legitimately -/// ERC-3643-native asset. Fail-closed: an unattested asset fails. +/// @dev Element-local minimal views over the ERC-3643 (T-REX) token surface and +/// its wired components. Repo precedent (HolderCount, IdentityUniqueness) +/// prefers element-local interface declarations to importing the heavyweight +/// vendored `lib/ERC-3643` interfaces (IToken pulls in IIdentityRegistry, +/// IModularCompliance, IERC20 ...). Only the probe functions B-02 calls are +/// declared here. `identityRegistry()`/`compliance()` are typed to return +/// `address` (ABI-compatible with the real IIdentityRegistry/IModularCompliance +/// returns) so no vendored types are needed. +interface IErc3643TokenProbe { + function identityRegistry() external view returns (address); + function compliance() external view returns (address); + function paused() external view returns (bool); + function isFrozen(address userAddress) external view returns (bool); + function getFrozenTokens(address userAddress) external view returns (uint256); + function balanceOf(address account) external view returns (uint256); +} + +interface IIdentityRegistryProbe { + function isVerified(address userAddress) external view returns (bool); +} + +interface IComplianceProbe { + function canTransfer(address from, address to, uint256 amount) external view returns (bool); +} + +/// @dev B-02-v1 Token Standard Conformance (mock, ASSET-side). Confirms that the +/// trade's target token is a genuine ERC-3643 (T-REX) machine, wired as +/// registered, and currently alive enough to permit this transfer. B-02 does +/// NOT judge party eligibility (A-03/A-13/A-04 do); its gate ⑤ probe merely +/// pre-asks the issuer-side machine its own answer (doc §1, §3.4). +/// +/// TWO REGIMES: +/// (1) Declaration-only (legacy, default). The operator attestation +/// `erc3643Native[asset]` stands in for listing-review gate ① (doc +/// §5.4). An attested asset with NO wiring registered PASSes on the +/// declaration alone — exact legacy behavior. An unattested asset fails +/// closed with code 1. +/// (2) Live-wiring (OPT-IN, per asset). Once `registerWiring` seals the +/// component addresses + implementation codehash, `check` runs doc +/// §5.4 gates ②–⑤ against the REAL token via view staticcalls (legal +/// from a view check). `clearWiring` returns the asset to regime (1). +/// +/// All external probes are wrapped in try/catch: any revert / nonconforming +/// target => code 1 (TOKEN_STANDARD_MISMATCH, fail-closed — doc §3.1). EOA / +/// undeployed targets are caught up-front by `asset.code.length == 0 => 1`. +/// +/// check(user=buyer, counterparty=seller, asset, amount): the engine passes +/// buyer as `user`, seller as `counterparty` (ComplianceEngine.sol:202). The +/// doc pseudocode's transfer `from` is the seller, `to` is the buyer. +/// +/// reasonCode table (n -> doc §6.1 failure-code name): +/// 1 TOKEN_STANDARD_MISMATCH declaration != ERC-3643 / no code / any probe revert (fail-closed) +/// 2 TOKEN_WIRING_DRIFT IR / MC binding or impl codehash != registered (⚠ security-event grade) +/// 3 TOKEN_PAUSED token globally paused by the issuer +/// 4 TOKEN_FROZEN_PARTY seller or buyer wallet frozen +/// 5 TOKEN_INSUFFICIENT_UNFROZEN seller free balance (balance - frozen) < amount +/// 6 TOKEN_TRANSFER_INELIGIBLE unverified buyer OR compliance canTransfer=false (two branches, one code) +/// REJECT_LISTING_NONCONFORMANT (doc §6.1) is the listing-pipeline channel +/// (L1~L4 review), not a per-trade check code — surfaced off-chain, not here. +/// +/// Production seam: the declaration check would be ERC-165 introspection / +/// trusted-token registry, and the wiring registered values would live in the +/// B-01 manifest card + governance constants (self-reference guard, doc §3.20). contract Erc3643Native is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "B-02-v1"; - /// @notice asset => attested ERC-3643-native. + /// @notice asset => attested ERC-3643-native (declaration check ①). mapping(address => bool) public erc3643Native; + /// @dev Live-wiring registration for gates ②–⑤. `registered == false` => the + /// asset stays in declaration-only regime (legacy behavior). + struct Wiring { + bool registered; + address identityRegistry; + address compliance; + bytes32 implCodehash; + } + + /// @notice asset => registered live-wiring (empty => declaration-only regime). + mapping(address => Wiring) public wiringOf; + event Erc3643NativeSet(address indexed asset, bool native_); + event WiringRegistered(address indexed asset, address identityRegistry, address compliance, bytes32 implCodehash); + event WiringCleared(address indexed asset); constructor() BaseElement(ElementMetadata({ @@ -36,35 +104,152 @@ contract Erc3643Native is BaseElement, Governed { category: ElementCategory.ASSET_ATTRIBUTE, version: "B-02-v1", temporal: TemporalNature.ONE_TIME, - // DETERMINISTIC (not ATTESTATION_BASED): the production form of this - // check is ERC-165/token-registry introspection — an onchain-decidable - // fact. The operator attestation is only the illustrative stand-in. + // DETERMINISTIC: both regimes are onchain-decidable — the declaration + // is the operator stand-in for listing review, the live regime is + // pure staticcall/compare against onchain state (doc §5.5). decidability: Decidability.DETERMINISTIC, timing: ObligationTiming.AT_TRADE_GATE, statefulness: Statefulness.STATELESS })) {} - /// @notice Attest whether `asset` is an ERC-3643-native (T-REX) token. - /// @dev Production seam: replace with an ERC-165 `supportsInterface` - /// check against the T-REX `IToken` interface ID, or a lookup in a - /// token/ERC-3643 trusted-token registry. + /// @notice Attest whether `asset` is an ERC-3643-native (T-REX) token + /// (declaration check ①). Legacy setter — signature and effect + /// preserved: an attested asset with no wiring PASSes; unattested + /// fails closed with code 1. function setErc3643Native(address asset, bool native_) external onlyOperator { erc3643Native[asset] = native_; emit Erc3643NativeSet(asset, native_); } - /// @dev ASSET-side check: ignores `user`/`counterparty`/`amount`/`context`. - /// Passes iff `asset` has been attested ERC-3643-native. Unattested - /// assets fail closed. - function check(address, address, address asset, uint256, bytes calldata) + /// @notice Seal the live-wiring for `asset` (doc §5.4 L5 registered-value + /// seal). Enables gates ②–⑤ on `check`. `implCodehash` is the expected + /// `asset.codehash`. onlyOperator (governance-driven activation). + /// @dev The doc's gate ② hashes the implementation-of for proxy tokens; this + /// mock hashes the asset address's own code directly (`asset.codehash`) — + /// the impl-of-proxy nuance is a documented production seam (doc §5.4, §3.20). + function registerWiring(address asset, address identityRegistry_, address compliance_, bytes32 implCodehash) + external + onlyOperator + { + wiringOf[asset] = Wiring({ + registered: true, identityRegistry: identityRegistry_, compliance: compliance_, implCodehash: implCodehash + }); + emit WiringRegistered(asset, identityRegistry_, compliance_, implCodehash); + } + + /// @notice Remove the live-wiring for `asset`, returning it to the + /// declaration-only regime. onlyOperator. + function clearWiring(address asset) external onlyOperator { + delete wiringOf[asset]; + emit WiringCleared(asset); + } + + /// @dev ASSET-side check. Regime (1): declaration-only — attested PASSes, + /// unattested => 1. Regime (2): live-wiring — gates ②–⑤ (doc §5.4). + /// user=buyer, counterparty=seller. + function check(address user, address counterparty, address asset, uint256 amount, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = erc3643Native[asset]; - // recipeId 0 is a placeholder; the engine re-encodes with the real recipeId. - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + // ① declaration check (legacy): unattested fails closed with code 1. + if (!erc3643Native[asset]) return (false, _code(1)); + + Wiring memory w = wiringOf[asset]; + // No wiring registered => stop after declaration (exact legacy behavior). + if (!w.registered) return (true, ReasonCodes.OK); + + // Live regime. from (transfer source) = seller = counterparty; to = buyer = user. + address seller = counterparty; + address buyer = user; + + // ① code presence (only meaningful under the live regime): an EOA / + // undeployed target is a standard mismatch. Also guards the try/catch + // below, whose return-data decoding of a no-code target would not be + // caught (Solidity try/catch catches reverts, not decode failures). + if (asset.code.length == 0) return (false, _code(1)); + + IErc3643TokenProbe token = IErc3643TokenProbe(asset); + + // ② wiring drift (⚠ security-event grade): IR / MC binding or impl codehash + // != registered => 2. Any probe revert => 1 (fail-closed, nonconforming). + try token.identityRegistry() returns (address ir) { + if (ir != w.identityRegistry) return (false, _code(2)); + } catch { + return (false, _code(1)); + } + try token.compliance() returns (address mc) { + if (mc != w.compliance) return (false, _code(2)); + } catch { + return (false, _code(1)); + } + if (asset.codehash != w.implCodehash) return (false, _code(2)); + + // ③ standard state: paused => 3. + try token.paused() returns (bool isPaused) { + if (isPaused) return (false, _code(3)); + } catch { + return (false, _code(1)); + } + + // ④ party freeze: either wallet frozen => 4. + try token.isFrozen(seller) returns (bool frozenSeller) { + if (frozenSeller) return (false, _code(4)); + } catch { + return (false, _code(1)); + } + try token.isFrozen(buyer) returns (bool frozenBuyer) { + if (frozenBuyer) return (false, _code(4)); + } catch { + return (false, _code(1)); + } + + // ④ free balance: (balance - frozen) < amount => 5. INCLUSIVE ≥ passes — + // exactly-equal free balance passes (doc §5.3). Skip when amount == 0: + // a zero-quantity transfer has nothing to clear. + if (amount != 0) { + uint256 bal; + uint256 frozen; + try token.balanceOf(seller) returns (uint256 b) { + bal = b; + } catch { + return (false, _code(1)); + } + try token.getFrozenTokens(seller) returns (uint256 f) { + frozen = f; + } catch { + return (false, _code(1)); + } + // Saturating subtraction: a conformant token keeps frozen <= balance; + // a pathological frozen > balance means zero free balance => 5, not an + // underflow revert. + uint256 free = bal > frozen ? bal - frozen : 0; + if (free < amount) return (false, _code(5)); + } + + // ⑤ delegation probe: pre-run the issuer-side gate. Call the REGISTERED + // component addresses (gate ② already proved they equal the token's) so + // "we only call verified addresses" holds on the code face (doc §5.4). + // Two distinct branches, single code 6 — the sub-cause is internal-log + // only per doc §6.1. + try IIdentityRegistryProbe(w.identityRegistry).isVerified(buyer) returns (bool verified) { + if (!verified) return (false, _code(6)); // sub: RECIPIENT_UNVERIFIED + } catch { + return (false, _code(1)); + } + try IComplianceProbe(w.compliance).canTransfer(seller, buyer, amount) returns (bool ok) { + if (!ok) return (false, _code(6)); // sub: COMPLIANCE_MODULE + } catch { + return (false, _code(1)); + } + + return (true, ReasonCodes.OK); + } + + /// @dev recipeId 0 is a placeholder; the engine re-encodes with the real recipeId. + function _code(uint32 n) internal pure returns (bytes32) { + return ReasonCodes.encode(0, ELEMENT_ID, n); } } diff --git a/src/compliance/elements/IdentityUniqueness.sol b/src/compliance/elements/IdentityUniqueness.sol index f176a34..6af14f5 100644 --- a/src/compliance/elements/IdentityUniqueness.sol +++ b/src/compliance/elements/IdentityUniqueness.sol @@ -19,18 +19,79 @@ import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; /// real claim/registry lookup — no production identity truth is hardcoded here. /// /// The uniqueness invariant lives in the setter (`bindIdentity`), not in -/// `check`: `check` only asks "is this wallet bound to *some* identity" and -/// fails closed on an unbound wallet. Rebinding the same (wallet, identityId) -/// pair is accepted as a no-op; binding either side to a *different* value on -/// the other side is rejected so the mapping stays 1:1 in both directions. +/// `check`: rebinding the same (wallet, identityId) pair is accepted as a +/// no-op; binding either side to a *different* value on the other side is +/// rejected so the mapping stays 1:1 in both directions. +/// +/// Doc walkthrough (A-04_identity-dedup.md §5.2/§5.3/§6.2/§6.5): per-identity +/// state (KYC claim, identity status, dedup status) is keyed by the bytes32 +/// identity id, not the wallet — this mirrors ONCHAINID being the person-level +/// anchor while `identityOf`/`walletOf` stay the wallet<->identity mapping. +/// +/// reasonCode table (n -> doc §6.2 name): +/// 1 -> IDENTITY_NOT_REGISTERED (legacy unbound-fail meaning preserved) +/// 2 -> KYC_CLAIM_MISSING +/// 3 -> KYC_CLAIM_INVALID_SIG +/// 4 -> UNTRUSTED_KYC_ISSUER +/// 5 -> KYC_CLAIM_EXPIRED +/// 6 -> IDENTITY_FROZEN +/// 7 -> IDENTITY_REVOKED +/// 8 -> DUPLICATE_IDENTITY +/// 9 -> REVIEW_IDENTITY_DUPLICATE_SUSPECTED +/// (doc's REVIEW vs FAIL distinction is carried only in the code number — +/// `check`'s boolean `passed` is false for both 8 and 9, since +/// IComplianceElement has no separate REVIEW outcome; see §5.5/§6.3.) contract IdentityUniqueness is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-04-v1"; + /// @dev Identity status per doc §4.2/§6.2 step 4. Default ACTIVE (enum zero). + enum IdentityStatus { + ACTIVE, + FROZEN, + REVOKED + } + + /// @dev Dedup status per doc §4.2/§6.2 step 5 (12g5-1(a)(6)/(b)(3)). Default + /// UNIQUE (enum zero) so an identity nobody has flagged is unique by default. + enum DedupStatus { + UNIQUE, + SUSPECTED_DUPLICATE, + CONFIRMED_DUPLICATE + } + + /// @dev KYC claim per doc §4.2/§5.2 steps 2-3. `maxAge == 0` means no expiry + /// (policy per doc §5.2-3: the arithmetic discipline itself — strict `>` + /// — is delegated to the A-11 freshness cascade element; A-04 inlines the + /// same comparison rather than making an external call). + struct KycClaim { + bool exists; + bool signatureValid; + bool issuerTrusted; + uint64 verifiedAt; + uint64 maxAge; + } + /// @dev wallet => identity id (bytes32(0) = unbound). mapping(address => bytes32) public identityOf; /// @dev identity id => wallet (address(0) = unbound). mapping(bytes32 => address) public walletOf; + /// @dev identity id => KYC claim. Keyed by identity, not wallet, so every + /// wallet bound to the same identity shares one claim. + mapping(bytes32 => KycClaim) public kycClaimOf; + /// @dev identity id => status (default ACTIVE). + mapping(bytes32 => IdentityStatus) public identityStatusOf; + /// @dev identity id => dedup status (default UNIQUE). + mapping(bytes32 => DedupStatus) public dedupStatusOf; + + /// @dev OPT-IN both-party gate (doc §5.2 `gate(from, to)`), default false. + /// Off by default because AMM pool sellers are not element-bound in this + /// mock DEX — a counterparty that is a pool address has no ONCHAINID and + /// would otherwise always fail code 1. When an operator flips this on + /// (e.g. for a bilateral/RFQ venue where the counterparty is a real + /// identified party), `check` runs the identical pipeline on `counterparty`. + bool public enforceCounterparty; + /// @dev No existing Errors.sol member fits this setter-time data-integrity /// guard: `Errors.ComplianceRejected(bytes32)` is reserved for the /// engine/router rejecting a whole trade decision by reasonCode, and @@ -43,6 +104,17 @@ contract IdentityUniqueness is BaseElement, Governed { event IdentityBound(address indexed wallet, bytes32 indexed identityId); event IdentityUnbound(address indexed wallet, bytes32 indexed identityId); + event KycClaimSet( + bytes32 indexed identityId, + bool exists, + bool signatureValid, + bool issuerTrusted, + uint64 verifiedAt, + uint64 maxAge + ); + event IdentityStatusSet(bytes32 indexed identityId, IdentityStatus status); + event DedupStatusSet(bytes32 indexed identityId, DedupStatus status); + event EnforceCounterpartySet(bool enabled); constructor() BaseElement(ElementMetadata({ @@ -57,7 +129,15 @@ contract IdentityUniqueness is BaseElement, Governed { {} /// @notice Bind `wallet` to `identityId`, enforcing the 1:1 invariant. - /// @dev Idempotent on rebinding the same (wallet, identityId) pair. + /// @dev Idempotent on rebinding the same (wallet, identityId) pair. Compat + /// seeding: the FIRST time an identity is bound, this also seeds a fully + /// valid `KycClaim` (exists/signatureValid/issuerTrusted true, verifiedAt + /// = now, maxAge = 0 = no expiry) so a bound wallet still PASSes `check` + /// with no further operator calls — the load-bearing legacy happy path. + /// Seeding only ensures a claim EXISTS: a later rebind (same pair, or a + /// new wallet after `unbindIdentity` — the doc §7.3 multi-wallet story is + /// collapsed to 1:1 in this mock) never overwrites a claim an operator + /// already customized via `setKycClaim` while `exists` is true. function bindIdentity(address wallet, bytes32 identityId) external onlyOperator { if (identityId == bytes32(0)) revert IdentityBindingConflict(); @@ -70,11 +150,20 @@ contract IdentityUniqueness is BaseElement, Governed { identityOf[wallet] = identityId; walletOf[identityId] = wallet; emit IdentityBound(wallet, identityId); + + if (!kycClaimOf[identityId].exists) { + kycClaimOf[identityId] = KycClaim({ + exists: true, signatureValid: true, issuerTrusted: true, verifiedAt: uint64(block.timestamp), maxAge: 0 + }); + emit KycClaimSet(identityId, true, true, true, uint64(block.timestamp), 0); + } } /// @notice Clear both directions of `wallet`'s binding, if any. /// @dev Idempotent: unbinding an unbound wallet is a silent no-op so indexers - /// never see a spurious `IdentityUnbound(wallet, 0)` event. + /// never see a spurious `IdentityUnbound(wallet, 0)` event. Per-identity + /// state (claim/status/dedup) is NOT cleared here — it belongs to the + /// identity, not the wallet<->identity edge, and survives a later rebind. function unbindIdentity(address wallet) external onlyOperator { bytes32 identityId = identityOf[wallet]; if (identityId == bytes32(0)) return; @@ -83,13 +172,94 @@ contract IdentityUniqueness is BaseElement, Governed { emit IdentityUnbound(wallet, identityId); } - function check(address user, address, address, uint256, bytes calldata) + /// @notice Operator sets the full KYC claim for an identity (doc §4.2/§5.2 steps 2-3). + function setKycClaim(bytes32 identityId, KycClaim calldata claim) external onlyOperator { + kycClaimOf[identityId] = claim; + emit KycClaimSet( + identityId, claim.exists, claim.signatureValid, claim.issuerTrusted, claim.verifiedAt, claim.maxAge + ); + } + + /// @notice Operator sets identity status (doc §6.2 FROZEN/REVOKED, §6.3). + function setIdentityStatus(bytes32 identityId, IdentityStatus status) external onlyOperator { + identityStatusOf[identityId] = status; + emit IdentityStatusSet(identityId, status); + } + + /// @notice Operator sets dedup status (doc §5.5/§6.2/§6.5 — off-chain + /// screening/investigation confirms the enum value; the chain only + /// reads it deterministically). + function setDedupStatus(bytes32 identityId, DedupStatus status) external onlyOperator { + dedupStatusOf[identityId] = status; + emit DedupStatusSet(identityId, status); + } + + /// @notice Operator toggles the both-party gate (see `enforceCounterparty` doc above). + function setEnforceCounterparty(bool enabled) external onlyOperator { + enforceCounterparty = enabled; + emit EnforceCounterpartySet(enabled); + } + + /// @dev doc §5.2 `check_A_04` pipeline, run on `user` always and on + /// `counterparty` only when `enforceCounterparty` is on (see field doc). + function check(address user, address counterparty, address, uint256, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = identityOf[user] != bytes32(0); - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + (passed, reasonCode) = _checkIdentity(user); + if (!passed) return (passed, reasonCode); + + if (enforceCounterparty) { + (passed, reasonCode) = _checkIdentity(counterparty); + } + } + + /// @dev doc §5.2 check order: unbound(1) -> claim missing(2) -> sig(3) -> + /// issuer(4) -> freshness, strict `>` (5) -> FROZEN(6) -> REVOKED(7) -> + /// CONFIRMED_DUPLICATE(8) -> SUSPECTED_DUPLICATE(9) -> PASS. + function _checkIdentity(address wallet) internal view returns (bool passed, bytes32 reasonCode) { + bytes32 idty = identityOf[wallet]; + if (idty == bytes32(0)) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + + KycClaim memory claim = kycClaimOf[idty]; + if (!claim.exists) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } + if (!claim.signatureValid) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + if (!claim.issuerTrusted) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 4)); + } + // Freshness — strict `>`, so exactly-at-maxAge PASSes (A-11 discipline). + // Age is 0 for a future/equal anchor, avoiding underflow. + if ( + claim.maxAge != 0 && block.timestamp > claim.verifiedAt + && block.timestamp - uint256(claim.verifiedAt) > uint256(claim.maxAge) + ) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 5)); + } + + IdentityStatus status = identityStatusOf[idty]; + if (status == IdentityStatus.FROZEN) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 6)); + } + if (status == IdentityStatus.REVOKED) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 7)); + } + + DedupStatus dedup = dedupStatusOf[idty]; + if (dedup == DedupStatus.CONFIRMED_DUPLICATE) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 8)); + } + if (dedup == DedupStatus.SUSPECTED_DUPLICATE) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 9)); + } + + return (true, bytes32(0)); } } diff --git a/src/compliance/elements/QualifiedPurchaser.sol b/src/compliance/elements/QualifiedPurchaser.sol index 827ab17..abc7f5d 100644 --- a/src/compliance/elements/QualifiedPurchaser.sol +++ b/src/compliance/elements/QualifiedPurchaser.sol @@ -12,15 +12,86 @@ import { Statefulness } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {LookThroughStatus} from "../../interfaces/compliance/ILookThroughSource.sol"; /// @dev A-13-v1 Qualified purchaser attestation (mock). Activated conditionally -/// by the 3(c)(7) fund recipe; settable per-user flag stands in for a claim. +/// by the ICA 3(c)(7) fund recipe (R3). Stands in for the off-chain +/// Trusted-Issuer determination of whether a *buyer* is a qualified +/// purchaser under 15 U.S.C. 80a-2(a)(51)(A) and the SEC rules thereunder. +/// The QP determination is a legal judgment (family relationship, trust +/// formation intent, investments valuation) that cannot run on-chain +/// (doc 5.5); Rule 2a51-1(h) reasonable-belief lets a Relying Person's +/// signed claim stand in for it. So `check` is a pure, deterministic +/// confirmation of a stored claim in the doc 5.2 order: existence/forgery +/// -> issuer trust -> freshness -> basis branch. The dollar thresholds +/// ($5M natural/family, $25M institutional, inclusive per doc 5.3) are +/// PRE-JUDGED by the Trusted Issuer off-chain — never computed here, so no +/// amounts appear on-chain (doc 5.2 step-4). +/// +/// Reason code map — `n` in `ReasonCodes.encode(0, ELEMENT_ID, n)` -> doc +/// 6.2 failure-code name: +/// 1 | FAIL_NOT_QP (no claim, or forged signature) +/// 2 | FAIL_QP_CLAIM_EXPIRED (freshness cap exceeded, strict >) +/// 3 | FAIL_UNTRUSTED_QP_CLAIM_ISSUER (issuer not in trusted registry) +/// 4 | FAIL_QP_LOOKTHROUGH_REQUIRED (family/trust, look-through NONE) +/// 5 | FAIL_QP_LOOKTHROUGH_NOT_COMPLETED (family/trust, look-through PENDING) +/// 6 | FAIL_TRUST_DISQUALIFIED (trust, look-through FAILED) +/// 7 | FAIL_FAMILY_CO_NOT_QP (family company, look-through FAILED) +/// 8 | FAIL_KNOWLEDGEABLE_EMP_NOT_QUALIFIED (KE covered company != fund key) +/// 9 | REVIEW_QP_UNCERTAIN (basis OTHER — manual review) contract QualifiedPurchaser is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-13-v1"; - mapping(address => bool) public qp; + /// @dev The five QP-qualifying paths (doc 3.1.1) plus the KE exclusion and an + /// OTHER catch-all for boundary cases routed to manual review. NATURAL = + /// 2(a)(51)(A)(i), FAMILY_COMPANY = (ii), TRUST = (iii), INSTITUTIONAL = + /// (iv) $25M any-person, QIB = Rule 2a51-1(g)(1) deemed, and + /// KNOWLEDGEABLE_EMPLOYEE = Rule 3c-5 exclusion. + enum QpBasis { + NONE, + NATURAL, + FAMILY_COMPANY, + TRUST, + INSTITUTIONAL, + QIB, + KNOWLEDGEABLE_EMPLOYEE, + OTHER + } + + struct QpClaim { + QpBasis basis; // NONE => code 1 (no QP claim) + bool signatureValid; // false => code 1 (doc folds forgery into NOT_QP) + bool issuerTrusted; // false => code 3 + uint64 verifiedAt; // freshness anchor (block.timestamp at issuance) + LookThroughStatus ltStatus; // FAMILY_COMPANY / TRUST beneficial-owner cascade (A-09) + bytes32 coveredCompany; // KE only: must equal the asset's fund key + } + + /// @notice buyer => attested QP claim (default: basis NONE => code 1). + mapping(address => QpClaim) public claimOf; + + /// @notice POLICY (operator-settable). Max age of a QP claim measured from + /// `verifiedAt`, implementing the 3(c)(7)(A) "at the time of + /// acquisition" requirement as a freshness cap (doc 5.3). Decipher + /// RECOMMENDS 1 year; a 5-year conservative option is available per + /// doc 5.3 — tune via `setFreshnessCap`. Coordinated with the Claim + /// Freshness element (A-11). + uint64 public freshnessCap = 365 days; event QualifiedPurchaserSet(address indexed investor, bool isQp); + // 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 QpClaimSet( + address indexed investor, + QpBasis basis, + bool signatureValid, + bool issuerTrusted, + uint64 verifiedAt, + LookThroughStatus ltStatus, + bytes32 coveredCompany + ); + event FreshnessCapSet(uint64 cap); constructor() BaseElement(ElementMetadata({ @@ -34,18 +105,115 @@ contract QualifiedPurchaser is BaseElement, Governed { })) {} + /// @notice Legacy setter (compatibility-preserving). `true` writes a fully + /// valid natural-person claim {NATURAL, valid signature, trusted + /// issuer, verifiedAt = now, no look-through, no covered company} so + /// a bare `setQp(user, true)` still PASSes `check`; `false` clears. function setQp(address user, bool isQp) external onlyOperator { - qp[user] = isQp; + if (isQp) { + claimOf[user] = QpClaim({ + basis: QpBasis.NATURAL, + signatureValid: true, + issuerTrusted: true, + verifiedAt: uint64(block.timestamp), + ltStatus: LookThroughStatus.NONE, + coveredCompany: bytes32(0) + }); + } else { + delete claimOf[user]; + } emit QualifiedPurchaserSet(user, isQp); } - function check(address user, address, address, uint256, bytes calldata) + /// @notice Writes the operator-attested rich QP claim for `user`. + function setQpClaim(address user, QpClaim calldata claim) external onlyOperator { + claimOf[user] = claim; + emit QpClaimSet( + user, + claim.basis, + claim.signatureValid, + claim.issuerTrusted, + claim.verifiedAt, + claim.ltStatus, + claim.coveredCompany + ); + } + + /// @notice Operator-settable POLICY freshness cap (see `freshnessCap`). + function setFreshnessCap(uint64 cap) external onlyOperator { + freshnessCap = cap; + emit FreshnessCapSet(cap); + } + + /// @notice Coarse legacy view: does `user` hold any QP claim. Preserves the + /// old `mapping(address => bool) public qp` getter shape for callers. + function qp(address user) public view returns (bool) { + return claimOf[user].basis != QpBasis.NONE; + } + + /// @dev `user` = prospective buyer; `asset` = the fund token (its address is + /// the fund identifier for the KE covered-company match). Returns the + /// first failing reason code in the doc 5.2 order. + function check(address user, address, address asset, uint256, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = qp[user]; - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + uint32 code = _evaluate(user, asset); + return (code == 0, code == 0 ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, code)); + } + + /// @dev doc 5.2 pipeline. 0 = PASS. + function _evaluate(address user, address asset) internal view returns (uint32) { + QpClaim memory c = claimOf[user]; + + // Step 1: existence + forgery (doc folds a forged signature into NOT_QP). + if (c.basis == QpBasis.NONE || !c.signatureValid) return 1; + + // Step 2: issuer trust. + if (!c.issuerTrusted) return 3; + + // Step 3: freshness — strict `>`, so exactly-at-cap PASSes (doc 5.3). Age + // is 0 for a future/equal anchor, avoiding underflow. + if (block.timestamp > c.verifiedAt && block.timestamp - c.verifiedAt > freshnessCap) { + return 2; + } + + // Step 4: basis branch. NATURAL/INSTITUTIONAL/QIB pass directly — their + // $5M/$25M thresholds and QIB status are pre-judged by the Trusted Issuer + // off-chain (doc 5.2 step-4), so no on-chain amount check. + if (c.basis == QpBasis.NATURAL || c.basis == QpBasis.INSTITUTIONAL || c.basis == QpBasis.QIB) { + return 0; + } + + // Family company / trust require a settled beneficial-owner look-through + // (A-09). NONE = no cascade recorded, PENDING = still running (suspend, + // not reject). FAILED disqualifies with a branch-specific code so the + // trust (6) vs family-company (7) distinction survives on-chain. + if (c.basis == QpBasis.FAMILY_COMPANY) { + if (c.ltStatus == LookThroughStatus.NONE) return 4; + if (c.ltStatus == LookThroughStatus.PENDING) return 5; + if (c.ltStatus == LookThroughStatus.FAILED) return 7; + return 0; // COMPLETED + } + + if (c.basis == QpBasis.TRUST) { + if (c.ltStatus == LookThroughStatus.NONE) return 4; + if (c.ltStatus == LookThroughStatus.PENDING) return 5; + if (c.ltStatus == LookThroughStatus.FAILED) return 6; + return 0; // COMPLETED + } + + // Knowledgeable Employee (Rule 3c-5): the exclusion is valid only for the + // buyer's own fund, so the claim's covered company must equal the fund + // key. The asset address IS the fund identifier for this mock. + if (c.basis == QpBasis.KNOWLEDGEABLE_EMPLOYEE) { + if (c.coveredCompany != bytes32(uint256(uint160(asset)))) return 8; + return 0; + } + + // basis == OTHER: cannot be auto-decided -> manual review. + return 9; } } diff --git a/src/compliance/elements/Sanctions.sol b/src/compliance/elements/Sanctions.sol index a00082a..2776f78 100644 --- a/src/compliance/elements/Sanctions.sol +++ b/src/compliance/elements/Sanctions.sol @@ -12,15 +12,102 @@ import { Statefulness } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; +import {LookThroughStatus} from "../../interfaces/compliance/ILookThroughSource.sol"; -/// @dev A-01-v1 Sanctions screen (mock). Blocks listed users at the trade gate. -/// Real list management is out of scope — a settable mapping stands in. +/// @dev A-01-v1 Sanctions screening (OFAC) — dual-pattern pre-trade gate (mock). +/// A-01 sits on sanctions law (IEEPA 50 U.S.C. §§1701-1705, OFAC 31 C.F.R. +/// Ch. V), NOT securities law, and is a strict-liability gate applied to +/// *both* parties of a trade (doc §1, §3.2 "양 당사자"). It is a hybrid of +/// two verification patterns (doc §8): +/// +/// Pattern A — wallet exact-match (deterministic, on-chain). The `blocked` +/// mapping IS the on-chain SDN wallet set (OFAC has listed digital-currency +/// addresses as SDN identifiers since 2018 — doc §3.8). `check` screens the +/// `user` (buyer) AND the `counterparty` (seller) wallet; an unlisted +/// counterparty (e.g. an AMM pool) passes. This leg is ALWAYS on and is the +/// exact legacy behavior. +/// +/// Pattern B — sanctions-screening claim (attestation, off-chain fuzzy name +/// match + entity 50%-Rule aggregation, verified on-chain). OPT-IN via +/// `claimRegimeEnabled` (default false => wallet-only, i.e. exactly the +/// legacy behavior). When enabled, the doc §5.2 claim pipeline runs for the +/// `user` always; for the `counterparty` only when `enforceCounterpartyClaim` +/// is also set (mock boundary — venue/pool sellers hold no claims; a real +/// deployment resolves seller identity off-chain first). +/// +/// A-09 seam: A-01's 50%-Rule aggregation shares A-09's recursive look-through +/// mechanism (doc §3.7, §9). Because that aggregation is settled wholesale +/// off-chain, we do NOT inject ILookThroughSource here (keeps the constructor +/// zero-arg and every call site unchanged); instead the settled +/// `LookThroughStatus ltStatus` is carried inside the operator-written claim. +/// +/// Reason code map — `n` in `ReasonCodes.encode(0, ELEMENT_ID, n)` -> doc §6 +/// failure-code name: +/// 1 | FAIL_SDN_WALLET_MATCH (wallet in SDN set — legacy code 1) +/// 2 | FAIL_SDN_IDENTITY_MATCH (name match score >= blockThresholdBps) +/// 3 | FAIL_50PCT_RULE (aggregate blocked ownership >= 50%) +/// 4 | FAIL_NO_SANCTIONS_CLAIM (no screening claim, regime on) +/// 5 | FAIL_UNTRUSTED_SANCTIONS_ISSUER (claim issuer not trusted) +/// 6 | FAIL_INVALID_SANCTIONS_SIGNATURE (claim signature invalid) +/// 7 | FAIL_SANCTIONS_CLAIM_EXPIRED (block.timestamp > expiry, strict >) +/// 8 | FAIL_SANCTIONS_CLAIM_STALE_LIST (screenedListVersion != currentListVersion) +/// 9 | FAIL_50PCT_LOOKTHROUGH_PENDING (entity ltStatus != COMPLETED) +/// 10 | REVIEW_SANCTIONS_UNCERTAIN (name match in [review, block) band) contract Sanctions is BaseElement, Governed { bytes32 internal constant ELEMENT_ID = "A-01-v1"; + /// @dev 50.00% in bps — STATUTORY (OFAC 50% Rule, doc §3.7 "합산·재귀 50% 이상"). + /// INCLUSIVE `>=`: exactly 5000 bps is blocked. + uint16 internal constant FIFTY_PCT_BPS = 5000; + + /// @notice Pattern A on-chain SDN wallet set. `setBlocked` writes it (legacy). mapping(address => bool) public blocked; + /// @notice Off-chain sanctions-screening claim per subject (Pattern B). Written + /// wholesale by an operator; `check` only verifies it (doc §8). + struct ScreeningClaim { + bool exists; // false => code 4 (regime on) + bool issuerTrusted; // false => 5 + bool signatureValid; // false => 6 + uint64 expiry; // block.timestamp > expiry => 7 (0 = no expiry) + uint32 screenedListVersion; // != currentListVersion => 8 (list update invalidates) + uint16 identityMatchBps; // [review, block) => 10 ; >= block => 2 (fuzzy name match) + bool isEntity; // true => run the 50%-Rule entity leg + LookThroughStatus ltStatus; // A-09 seam: != COMPLETED => 9 (attested wholesale) + uint16 blockedOwnershipBps; // aggregate blocked ownership; >= 5000 => 3 (recursive, off-chain) + } + + /// @notice subject wallet => attested screening claim (Pattern B). + mapping(address => ScreeningClaim) public claims; + + /// @notice Pattern B master switch. Default false => wallet-only (legacy behavior). + bool public claimRegimeEnabled; + + /// @notice When true (and regime on), the claim pipeline also runs for the + /// counterparty. Default false — mock boundary (pool sellers hold no + /// claims); the wallet leg still screens the counterparty regardless. + bool public enforceCounterpartyClaim; + + /// @notice Current SDN list version. A claim screened against an older version + /// is stale (code 8). Default 0 matches a default (version-0) claim. + uint32 public currentListVersion; + + /// @notice Fuzzy name-match bands — operator-set POLICY values (doc §5, §12 OI-2; + /// doc fixes only the 50% ownership threshold). Score in + /// [reviewThresholdBps, blockThresholdBps) => manual review (10); + /// >= blockThresholdBps => hard block (2). Defaults are illustrative. + uint16 public reviewThresholdBps = 7500; + uint16 public blockThresholdBps = 9500; + event SanctionsBlockedSet(address indexed account, bool blocked); + event ClaimRegimeSet(bool claimRegimeEnabled, bool enforceCounterpartyClaim); + event CurrentListVersionSet(uint32 version); + event ScreeningThresholdsSet(uint16 reviewThresholdBps, uint16 blockThresholdBps); + // Enum param canonicalizes to uint8 in the signature; tests re-declare ltStatus + // as uint8 to match (Solidity 0.8.17 cannot `emit` a contract's event by name). + event ScreeningClaimSet( + address indexed subject, bool exists, bool isEntity, uint16 identityMatchBps, LookThroughStatus ltStatus + ); constructor() BaseElement(ElementMetadata({ @@ -34,19 +121,93 @@ contract Sanctions is BaseElement, Governed { })) {} + /// @notice Adds/removes a wallet from the on-chain SDN set (Pattern A). Legacy + /// signature and effect preserved: an unblocked wallet still PASSes. function setBlocked(address user, bool isBlocked) external onlyOperator { blocked[user] = isBlocked; emit SanctionsBlockedSet(user, isBlocked); } - function check(address user, address, address, uint256, bytes calldata) + /// @notice Toggles the Pattern B claim regime and counterparty enforcement. + function setClaimRegime(bool enabled, bool enforceCounterparty) external onlyOperator { + claimRegimeEnabled = enabled; + enforceCounterpartyClaim = enforceCounterparty; + emit ClaimRegimeSet(enabled, enforceCounterparty); + } + + /// @notice Writes the operator-attested screening claim for `subject`. + function setClaim(address subject, ScreeningClaim calldata claim) external onlyOperator { + claims[subject] = claim; + emit ScreeningClaimSet(subject, claim.exists, claim.isEntity, claim.identityMatchBps, claim.ltStatus); + } + + /// @notice Bumps the current SDN list version (invalidates stale claims => 8). + function setCurrentListVersion(uint32 version) external onlyOperator { + currentListVersion = version; + emit CurrentListVersionSet(version); + } + + /// @notice Sets the fuzzy name-match POLICY bands. Requires review <= block. + function setScreeningThresholds(uint16 reviewBps, uint16 blockBps) external onlyOperator { + require(reviewBps <= blockBps, "review>block"); + reviewThresholdBps = reviewBps; + blockThresholdBps = blockBps; + emit ScreeningThresholdsSet(reviewBps, blockBps); + } + + /// @dev Screens both parties (doc §5.2). Order: wallet(user) -> wallet(cpty) + /// -> [regime] claim pipeline per subject (user always; cpty iff enforced). + /// `asset`/`amount`/`context` are unused — sanctions is party-scoped. + function check(address user, address counterparty, address, uint256, bytes calldata) external view override returns (bool passed, bytes32 reasonCode) { - passed = !blocked[user]; - // recipeId 0 is a placeholder; the engine re-encodes with the real recipeId. - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + // (1) Pattern A — wallet exact-match, both parties. Unlisted cpty passes. + if (blocked[user]) return (false, _code(1)); + if (blocked[counterparty]) return (false, _code(1)); + + // (2) Pattern B — opt-in claim pipeline. + if (claimRegimeEnabled) { + uint32 c = _screenClaim(user); + if (c != 0) return (false, _code(c)); + if (enforceCounterpartyClaim) { + c = _screenClaim(counterparty); + if (c != 0) return (false, _code(c)); + } + } + return (true, bytes32(0)); + } + + /// @dev Runs the doc §5.2 claim pipeline for one subject. Returns the first + /// failing code, or 0 on pass. Order: exists(4) -> issuer(5) -> sig(6) -> + /// expiry(7, strict >) -> list version(8) -> name-match band (review 10 / + /// block 2) -> entity leg: ltStatus != COMPLETED(9) -> ownership >= 50%(3). + function _screenClaim(address subject) internal view returns (uint32) { + ScreeningClaim memory c = claims[subject]; + if (!c.exists) return 4; + if (!c.issuerTrusted) return 5; + if (!c.signatureValid) return 6; + if (c.expiry != 0 && block.timestamp > c.expiry) return 7; // strict > ; 0 = no expiry + if (c.screenedListVersion != currentListVersion) return 8; + + // Fuzzy name-match bands (doc §5.2). Review band is [review, block); the + // block band is >= block. Disjoint, so order among the two is immaterial. + if (c.identityMatchBps >= reviewThresholdBps && c.identityMatchBps < blockThresholdBps) return 10; + if (c.identityMatchBps >= blockThresholdBps) return 2; + + // Entity 50%-Rule leg (doc §3.7). Look-through must be COMPLETED first + // (else pending => 9), then aggregate blocked ownership >= 50% => 3. + if (c.isEntity) { + if (c.ltStatus != LookThroughStatus.COMPLETED) return 9; + if (c.blockedOwnershipBps >= FIFTY_PCT_BPS) return 3; + } + return 0; + } + + /// @dev recipeId 0 is a placeholder; the engine re-encodes with the real recipeId. + function _code(uint32 n) private pure returns (bytes32) { + return ReasonCodes.encode(0, ELEMENT_ID, n); } } diff --git a/test/unit/compliance/elements/AccreditedInvestor.t.sol b/test/unit/compliance/elements/AccreditedInvestor.t.sol new file mode 100644 index 0000000..37a51a6 --- /dev/null +++ b/test/unit/compliance/elements/AccreditedInvestor.t.sol @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {AccreditedInvestor} from "../../../../src/compliance/elements/AccreditedInvestor.sol"; +import {LookThroughStatus} from "../../../../src/interfaces/compliance/ILookThroughSource.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import { + ElementMetadata, + ElementCategory, + Decidability, + ObligationTiming, + Statefulness, + TemporalNature +} from "../../../../src/types/ComplianceTypes.sol"; + +contract AccreditedInvestorTest is Test { + // Re-declared to match AccreditedInvestor'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 AccreditedInvestorSet(address indexed investor, bool isAccredited); + event AiClaimSet( + address indexed investor, + bool exists, + bool issuerTrusted, + bool signatureValid, + uint64 expiry, + bool verificationBasisAccepted, + AccreditedInvestor.AiClaimBasis basis, + LookThroughStatus ltStatus, + bool reviewRequired + ); + event Asset506cVerificationRequirementSet(address indexed asset, bool required); + event AssetSec4a7PathSet(address indexed asset, bool enabled); + + bytes32 internal constant ELEMENT_ID = "A-03-v1"; + + address internal user = address(0xA11CE); + address internal asset = address(0xBEEF); + 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; + + AccreditedInvestor internal element; + + function setUp() public { + element = new AccreditedInvestor(); + vm.warp(START); + } + + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), ELEMENT_ID, n)); + } + + /// @dev Fully-valid DIRECT claim — same shape `setAccredited(user, true)` writes. + function _validClaim() internal pure returns (AccreditedInvestor.AiClaim memory) { + return AccreditedInvestor.AiClaim({ + exists: true, + issuerTrusted: true, + signatureValid: true, + expiry: 0, + verificationBasisAccepted: true, + basis: AccreditedInvestor.AiClaimBasis.DIRECT, + ltStatus: LookThroughStatus.NONE, + reviewRequired: false + }); + } + + function _check() internal view returns (bool passed, bytes32 rc) { + return element.check(user, address(0), asset, 0, ""); + } + + // --------------------------------------------------------------- + // 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.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_setAccredited_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setAccredited(user, true); + } + + function test_setClaim_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setClaim(user, _validClaim()); + } + + function test_setRequires506cVerification_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setRequires506cVerification(asset, true); + } + + function test_setSec4a7Path_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setSec4a7Path(asset, true); + } + + // --------------------------------------------------------------- + // Legacy compatibility — setAccredited + accredited view + // --------------------------------------------------------------- + + function test_legacy_default_fails_code1() public { + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(1)); // NO_AI_CLAIM — legacy "not accredited" meaning preserved + } + + function test_legacy_setAccredited_true_alone_passes() public { + element.setAccredited(user, true); + (bool passed, bytes32 rc) = _check(); + assertTrue(passed); + assertEq(rc, bytes32(0)); + assertTrue(element.accredited(user)); + } + + function test_legacy_setAccredited_true_writes_fully_valid_claim() public { + element.setAccredited(user, true); + ( + bool exists, + bool issuerTrusted, + bool signatureValid, + uint64 expiry, + bool verificationBasisAccepted, + AccreditedInvestor.AiClaimBasis basis, + LookThroughStatus ltStatus, + bool reviewRequired + ) = element.claimOf(user); + assertTrue(exists); + assertTrue(issuerTrusted); + assertTrue(signatureValid); + assertEq(expiry, 0); + assertTrue(verificationBasisAccepted); + assertEq(uint256(basis), uint256(AccreditedInvestor.AiClaimBasis.DIRECT)); + assertEq(uint256(ltStatus), uint256(LookThroughStatus.NONE)); + assertFalse(reviewRequired); + } + + function test_legacy_setAccredited_false_clears_claim_and_view() public { + element.setAccredited(user, true); + element.setAccredited(user, false); + assertFalse(element.accredited(user)); + (bool exists,,,,,,,) = element.claimOf(user); + assertFalse(exists); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(1)); + } + + function test_legacy_setAccredited_emits() public { + vm.expectEmit(true, false, false, true); + emit AccreditedInvestorSet(user, true); + element.setAccredited(user, true); + } + + // Legacy claim stays green even under the new opt-in strictness flags: + // it carries verificationBasisAccepted=true and basis=DIRECT. + function test_legacy_claim_passes_with_506c_and_4a7_flags_on() public { + element.setAccredited(user, true); + element.setRequires506cVerification(asset, true); + element.setSec4a7Path(asset, true); + (bool passed,) = _check(); + assertTrue(passed); + } + + // --------------------------------------------------------------- + // setClaim <-> accredited view consistency + // --------------------------------------------------------------- + + function test_setClaim_syncs_accredited_view_to_exists() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + element.setClaim(user, c); + assertTrue(element.accredited(user)); + + // Coarse view tracks existence only — even a claim that would FAIL + // the pipeline (untrusted issuer) keeps accredited == exists. + c.issuerTrusted = false; + element.setClaim(user, c); + assertTrue(element.accredited(user)); + + c.exists = false; + element.setClaim(user, c); + assertFalse(element.accredited(user)); + } + + function test_setClaim_emits() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + vm.expectEmit(true, false, false, true); + emit AiClaimSet( + user, true, true, true, 0, true, AccreditedInvestor.AiClaimBasis.DIRECT, LookThroughStatus.NONE, false + ); + element.setClaim(user, c); + } + + function test_asset_flag_setters_emit() public { + vm.expectEmit(true, false, false, true); + emit Asset506cVerificationRequirementSet(asset, true); + element.setRequires506cVerification(asset, true); + + vm.expectEmit(true, false, false, true); + emit AssetSec4a7PathSet(asset, true); + element.setSec4a7Path(asset, true); + } + + // --------------------------------------------------------------- + // Pipeline codes, in check order + // --------------------------------------------------------------- + + function test_code1_no_claim() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.exists = false; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(1)); + } + + function test_code2_untrusted_issuer() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.issuerTrusted = false; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(2)); + } + + function test_code3_invalid_signature() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.signatureValid = false; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(3)); + } + + function test_code4_expired_strict_boundary() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.expiry = START + 100; + element.setClaim(user, c); + + // Exactly at expiry PASSes (strict `>`). + vm.warp(START + 100); + (bool passed, bytes32 rc) = _check(); + assertTrue(passed); + assertEq(rc, bytes32(0)); + + // One second past expiry FAILs. + vm.warp(START + 101); + (passed, rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(4)); + } + + function test_expiry_zero_means_no_expiry() public { + element.setClaim(user, _validClaim()); // expiry = 0 + vm.warp(START + 3650 days); + (bool passed,) = _check(); + assertTrue(passed); + } + + function test_code5_506c_verification_not_established() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.verificationBasisAccepted = false; + element.setClaim(user, c); + + // Flag off (default): verification basis not enforced -> PASS. + (bool passed, bytes32 rc) = _check(); + assertTrue(passed); + + // Flag on for this asset -> code 5. + element.setRequires506cVerification(asset, true); + (passed, rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(5)); + + // Other assets unaffected (per-asset activation). + (passed,) = element.check(user, address(0), address(0xCAFE), 0, ""); + assertTrue(passed); + } + + function test_code6_4a7_claim_shell_without_category() public { + // Doc-ambiguity resolution (see contract NatSpec): code 6 is scoped to a + // claim that exists and is otherwise valid but carries no AI category + // (basis == NONE) on a §4(a)(7)-path asset. + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.NONE; + element.setClaim(user, c); + + // Flag off (default, e.g. Rule 144 resale — buyer AI not required): + // a basis-NONE shell is not rejected by the 4(a)(7) leg. + (bool passed, bytes32 rc) = _check(); + assertTrue(passed); + + element.setSec4a7Path(asset, true); + (passed, rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(6)); + } + + function test_4a7_missing_claim_still_fails_code1_not_6() public { + // Doc §7: "§4(a)(7) resale에서 buyer AI claim 없음 -> FAIL_NO_AI_CLAIM". + element.setSec4a7Path(asset, true); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(1)); + } + + function test_code7_lookthrough_not_completed() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.ALL_EQUITY_OWNERS; + + // NONE, PENDING and FAILED all map to code 7 (doc has no separate + // look-through-FAILED code for A-03 — documented resolution). + LookThroughStatus[3] memory notCompleted = + [LookThroughStatus.NONE, LookThroughStatus.PENDING, LookThroughStatus.FAILED]; + for (uint256 i = 0; i < notCompleted.length; i++) { + c.ltStatus = notCompleted[i]; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(7)); + } + + // COMPLETED passes. + c.ltStatus = LookThroughStatus.COMPLETED; + element.setClaim(user, c); + (bool ok, bytes32 rc2) = _check(); + assertTrue(ok); + assertEq(rc2, bytes32(0)); + } + + function test_code8_category_unsupported() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.OTHER; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(8)); + } + + function test_code9_review_required() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.reviewRequired = true; + element.setClaim(user, c); + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(9)); + } + + // --------------------------------------------------------------- + // Ordering + // --------------------------------------------------------------- + + function test_order_issuer_before_signature() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.issuerTrusted = false; + c.signatureValid = false; + element.setClaim(user, c); + (, bytes32 rc) = _check(); + assertEq(rc, _code(2)); + } + + function test_order_expiry_before_506c() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.expiry = START - 1; + c.verificationBasisAccepted = false; + element.setClaim(user, c); + element.setRequires506cVerification(asset, true); + (, bytes32 rc) = _check(); + assertEq(rc, _code(4)); + } + + function test_order_506c_before_4a7_shell() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.verificationBasisAccepted = false; + c.basis = AccreditedInvestor.AiClaimBasis.NONE; + element.setClaim(user, c); + element.setRequires506cVerification(asset, true); + element.setSec4a7Path(asset, true); + (, bytes32 rc) = _check(); + assertEq(rc, _code(5)); + } + + function test_order_4a7_shell_before_lookthrough_and_review() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.NONE; + c.reviewRequired = true; + element.setClaim(user, c); + element.setSec4a7Path(asset, true); + (, bytes32 rc) = _check(); + assertEq(rc, _code(6)); + } + + function test_order_category_before_review() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.OTHER; + c.reviewRequired = true; + element.setClaim(user, c); + (, bytes32 rc) = _check(); + assertEq(rc, _code(8)); + } + + function test_order_lookthrough_before_review() public { + AccreditedInvestor.AiClaim memory c = _validClaim(); + c.basis = AccreditedInvestor.AiClaimBasis.ALL_EQUITY_OWNERS; + c.ltStatus = LookThroughStatus.PENDING; + c.reviewRequired = true; + element.setClaim(user, c); + (, bytes32 rc) = _check(); + assertEq(rc, _code(7)); + } +} diff --git a/test/unit/compliance/elements/AssetClassification.t.sol b/test/unit/compliance/elements/AssetClassification.t.sol index d68e850..e1b8f7f 100644 --- a/test/unit/compliance/elements/AssetClassification.t.sol +++ b/test/unit/compliance/elements/AssetClassification.t.sol @@ -12,6 +12,19 @@ contract AssetClassificationTest is Test { // qualified name in an `emit` statement; that requires >=0.8.22). event ClassificationSet(address indexed asset, bytes32 classification); + event CardSet( + address indexed asset, + AssetClassification.CardStatus status, + bytes32 classification, + uint32 coreVersion, + uint32 approvedVersion, + uint64 approvedAt, + uint64 factsAsOf, + uint64 maxFactAge + ); + + event ActivationDelaySet(uint64 delay); + bytes32 internal constant REG_D = bytes32("REG_D"); bytes32 internal constant REG_A = bytes32("REG_A"); @@ -106,4 +119,228 @@ contract AssetClassificationTest is Test { assertFalse(failed2); assertEq(failed1, failed2); } + + // --- manifest-integrity card upgrade (doc §5.2–§5.4, §6.1) -------------- + + // Mirror of ReasonCodes.encode(0, "B-01-v1", n) for pinning exact codes. + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("B-01-v1"), n)); + } + + // A fully-live card with the given classification (the shape setClassification writes). + function _liveCard(bytes32 cls) internal view returns (AssetClassification.AssetCard memory) { + return AssetClassification.AssetCard({ + status: AssetClassification.CardStatus.ACTIVE, + classification: cls, + coreVersion: 1, + approvedVersion: 1, + approvedAt: uint64(block.timestamp), + factsAsOf: uint64(block.timestamp), + maxFactAge: 0 + }); + } + + function test_setClassification_writesFullyLiveCard() public { + element.setClassification(asset, REG_D); + + ( + AssetClassification.CardStatus status, + bytes32 cls, + uint32 coreVersion, + uint32 approvedVersion, + uint64 approvedAt, + uint64 factsAsOf, + uint64 maxFactAge + ) = element.cardOf(asset); + assertEq(uint256(status), uint256(AssetClassification.CardStatus.ACTIVE)); + assertEq(cls, REG_D); + assertEq(coreVersion, 1); + assertEq(approvedVersion, 1); + assertEq(approvedAt, uint64(block.timestamp)); + assertEq(factsAsOf, uint64(block.timestamp)); + assertEq(maxFactAge, 0); + + // classificationOf view still reflects the card's classification field. + assertEq(element.classificationOf(asset), REG_D); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // Code 1 — unattested asset (status NONE). Legacy "missing/unclassified" + // meaning preserved. + function test_check_missingCard_returnsCode1() public { + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), otherAsset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(1)); + } + + // Code 2 — SUSPENDED (watcher hash mismatch / emergency halt converge here). + function test_check_suspended_returnsCode2() public { + AssetClassification.AssetCard memory card = _liveCard(REG_D); + card.status = AssetClassification.CardStatus.SUSPENDED; + element.setCard(asset, card); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + // Code 3 — coreVersion outside the approved set (governance-bypass signal). + function test_check_versionUnapproved_returnsCode3() public { + AssetClassification.AssetCard memory card = _liveCard(REG_D); + card.coreVersion = 4; // references v4 while only v1 is approved + element.setCard(asset, card); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(3)); + } + + // Code 5 — INV-C: attested but classification != requiredClassification. + // This is the previously-code-1 "attested wrong class" case (doc §5.2 ④). + function test_check_wrongClassification_returnsCode5() public { + element.setClassification(asset, REG_A); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, _code(5)); + } + + // Code 4 — time-lock pending. Boundary is INCLUSIVE: exactly at + // approvedAt + delay PASSes; one second before fails. + function test_check_timeLock_boundaryIsInclusive() public { + uint64 delay = 1000; + element.setActivationDelay(delay); + + AssetClassification.AssetCard memory card = _liveCard(REG_D); + // Anchor approvedAt at a known timestamp. + vm.warp(10_000); + card.approvedAt = uint64(block.timestamp); + card.factsAsOf = uint64(block.timestamp); + element.setCard(asset, card); + + // One second before the boundary => FAIL_VERSION_PENDING (code 4). + vm.warp(uint256(card.approvedAt) + delay - 1); + (bool passedBefore, bytes32 codeBefore) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertFalse(passedBefore); + assertEq(codeBefore, _code(4)); + + // Exactly AT the boundary => effective (PASS). now >= approvedAt + delay. + vm.warp(uint256(card.approvedAt) + delay); + (bool passedAt, bytes32 codeAt) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passedAt); + assertEq(codeAt, bytes32(0)); + } + + // Code 6 — freshness. Boundary is STRICT `>`: exactly at maxFactAge PASSes; + // one second past fails. Opposite direction to the time-lock (doc §5.3). + function test_check_freshness_boundaryIsStrict() public { + uint64 maxAge = 500; + + AssetClassification.AssetCard memory card = _liveCard(REG_D); + vm.warp(10_000); + card.approvedAt = uint64(block.timestamp); + card.factsAsOf = uint64(block.timestamp); + card.maxFactAge = maxAge; + element.setCard(asset, card); + + // now - factsAsOf == maxAge exactly => still fresh (PASS). + vm.warp(uint256(card.factsAsOf) + maxAge); + (bool passedAt, bytes32 codeAt) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passedAt); + assertEq(codeAt, bytes32(0)); + + // now - factsAsOf == maxAge + 1 => stale (code 6). + vm.warp(uint256(card.factsAsOf) + maxAge + 1); + (bool passedPast, bytes32 codePast) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertFalse(passedPast); + assertEq(codePast, _code(6)); + } + + // maxFactAge 0 disables the freshness check entirely (legacy default). + function test_check_freshnessDisabledWhenMaxFactAgeZero() public { + element.setClassification(asset, REG_D); // maxFactAge 0 + vm.warp(block.timestamp + 3650 days); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // A future factsAsOf anchor must not underflow the age subtraction and + // panic — age is treated as 0 (fresh), so `check` PASSes even with a + // nonzero maxFactAge (mirrors QualifiedPurchaser's freshness guard). + function test_check_futureFactsAsOf_passesWithoutRevert() public { + vm.warp(10_000); + AssetClassification.AssetCard memory card = _liveCard(REG_D); + card.factsAsOf = uint64(block.timestamp + 4000); // anchor in the future + card.maxFactAge = 500; + element.setCard(asset, card); + + (bool passed, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + // Evaluation order: SUSPENDED (2) is reported before a version mismatch (3) + // when both are wrong — confirms status precedes version in the pipeline. + function test_check_evaluationOrder_statusBeforeVersion() public { + AssetClassification.AssetCard memory card = _liveCard(REG_D); + card.status = AssetClassification.CardStatus.SUSPENDED; + card.coreVersion = 9; // also version-mismatched + element.setCard(asset, card); + + (, bytes32 reasonCode) = element.check(address(0xA11CE), address(0), asset, 0, ""); + assertEq(reasonCode, _code(2)); + } + + function test_setCard_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setCard(asset, _liveCard(REG_D)); + } + + function test_setCard_emitsEvent_andStores() public { + AssetClassification.AssetCard memory card = _liveCard(REG_D); + card.coreVersion = 2; + card.approvedVersion = 2; + card.maxFactAge = 7; + + vm.expectEmit(true, false, false, true); + emit CardSet( + asset, + AssetClassification.CardStatus.ACTIVE, + REG_D, + card.coreVersion, + card.approvedVersion, + card.approvedAt, + card.factsAsOf, + card.maxFactAge + ); + element.setCard(asset, card); + + (,, uint32 coreVersion, uint32 approvedVersion,,, uint64 maxFactAge) = element.cardOf(asset); + assertEq(coreVersion, 2); + assertEq(approvedVersion, 2); + assertEq(maxFactAge, 7); + } + + function test_setActivationDelay_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.setActivationDelay(1000); + } + + function test_setActivationDelay_emitsEvent_andStores() public { + vm.expectEmit(false, false, false, true); + emit ActivationDelaySet(1234); + element.setActivationDelay(1234); + assertEq(element.activationDelay(), 1234); + } + + function test_activationDelay_defaultsToZero() public { + assertEq(element.activationDelay(), 0); + } } diff --git a/test/unit/compliance/elements/Erc3643Native.t.sol b/test/unit/compliance/elements/Erc3643Native.t.sol index a5c5c58..3b292b6 100644 --- a/test/unit/compliance/elements/Erc3643Native.t.sol +++ b/test/unit/compliance/elements/Erc3643Native.t.sol @@ -12,6 +12,91 @@ import { Statefulness } from "../../../../src/types/ComplianceTypes.sol"; import {Errors} from "../../../../src/libraries/Errors.sol"; +import {ReasonCodes} from "../../../../src/libraries/ReasonCodes.sol"; + +/// @dev Minimal mock exposing the six ERC-3643 probe functions B-02 calls, plus a +/// wired IR / MC pair. A hand-rolled mock (not the heavyweight vendored +/// TREXSuite fixture, which test/integration uses) keeps this a true unit +/// file — B-02's gate logic only depends on the return values of the six +/// views, so exercising every code/boundary is cleaner with a controllable +/// mock than with a fully-wired T-REX deployment. (Reported per task note.) +contract MockIdentityRegistry { + mapping(address => bool) public verified; + + function setVerified(address who, bool v) external { + verified[who] = v; + } + + function isVerified(address userAddress) external view returns (bool) { + return verified[userAddress]; + } +} + +contract MockCompliance { + bool public allow = true; + + function setAllow(bool a) external { + allow = a; + } + + function canTransfer(address, address, uint256) external view returns (bool) { + return allow; + } +} + +contract MockToken { + address public identityRegistry; + address public compliance; + bool public paused; + mapping(address => bool) public isFrozen; + mapping(address => uint256) public balanceOf; + mapping(address => uint256) public getFrozenTokens; + + constructor(address ir, address mc) { + identityRegistry = ir; + compliance = mc; + } + + function setPaused(bool p) external { + paused = p; + } + + function setFrozen(address who, bool f) external { + isFrozen[who] = f; + } + + function setBalance(address who, uint256 bal) external { + balanceOf[who] = bal; + } + + function setFrozenTokens(address who, uint256 amt) external { + getFrozenTokens[who] = amt; + } + + function setIdentityRegistry(address ir) external { + identityRegistry = ir; + } + + function setCompliance(address mc) external { + compliance = mc; + } +} + +/// @dev A token-shaped contract whose probes revert — exercises the fail-closed +/// (=> code 1) try/catch path with a deployed (code.length > 0) target. +contract RevertingToken { + function identityRegistry() external pure returns (address) { + revert("nope"); + } + + function compliance() external pure returns (address) { + revert("nope"); + } + + function paused() external pure returns (bool) { + revert("nope"); + } +} contract Erc3643NativeTest is Test { Erc3643Native internal element; @@ -20,16 +105,26 @@ contract Erc3643NativeTest is Test { address internal operator = address(0xBEEF); address internal stranger = address(0xDEAD); address internal asset = address(0x7000); - address internal user = address(0xA11CE); - address internal counterparty = address(0xB0B); + address internal user = address(0xA11CE); // buyer + address internal counterparty = address(0xB0B); // seller event Erc3643NativeSet(address indexed asset, bool native_); + event WiringRegistered(address indexed asset, address identityRegistry, address compliance, bytes32 implCodehash); + event WiringCleared(address indexed asset); function setUp() public { element = new Erc3643Native(); element.setOperator(operator, true); } + function _code(uint32 n) internal pure returns (bytes32) { + return ReasonCodes.encode(0, bytes32("B-02-v1"), n); + } + + // ------------------------------------------------------------------ + // Legacy / declaration-only regime (existing behavior — must stay green) + // ------------------------------------------------------------------ + function test_metadata_fields() public { ElementMetadata memory m = element.elementMetadata(); assertEq(m.elementId, bytes32("B-02-v1")); @@ -61,7 +156,7 @@ contract Erc3643NativeTest is Test { function test_check_fails_when_unattested_default_state() public { (bool passed, bytes32 reasonCode) = element.check(user, counterparty, asset, 100, ""); assertFalse(passed); - assertTrue(reasonCode != bytes32(0)); + assertEq(reasonCode, _code(1)); } function test_check_passes_after_attestation() public { @@ -83,10 +178,10 @@ contract Erc3643NativeTest is Test { element.setErc3643Native(asset, false); (bool passedAfterRevoke, bytes32 reasonCode) = element.check(user, counterparty, asset, 100, ""); assertFalse(passedAfterRevoke); - assertTrue(reasonCode != bytes32(0)); + assertEq(reasonCode, _code(1)); } - function test_check_ignores_user_and_counterparty() public { + function test_check_ignores_user_and_counterparty_declaration_only() public { vm.prank(operator); element.setErc3643Native(asset, true); @@ -100,4 +195,212 @@ contract Erc3643NativeTest is Test { (bool passedC,) = element.check(address(0x1234), address(0x5678), otherAsset, 1, ""); assertFalse(passedC); } + + // ------------------------------------------------------------------ + // Live-wiring regime (gates ②–⑤) + // ------------------------------------------------------------------ + + MockIdentityRegistry internal ir; + MockCompliance internal mc; + MockToken internal token; + + /// @dev Attest + register wiring for a fully-live, transfer-ready mock token. + /// Seller has 1000 balance, 0 frozen; buyer verified; compliance allows. + function _standUpLiveToken() internal returns (address tokenAddr) { + ir = new MockIdentityRegistry(); + mc = new MockCompliance(); + token = new MockToken(address(ir), address(mc)); + tokenAddr = address(token); + + ir.setVerified(user, true); // buyer verified + token.setBalance(counterparty, 1000); // seller balance + + vm.startPrank(operator); + element.setErc3643Native(tokenAddr, true); + element.registerWiring(tokenAddr, address(ir), address(mc), tokenAddr.codehash); + vm.stopPrank(); + } + + function test_registerWiring_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.registerWiring(asset, address(1), address(2), bytes32("x")); + } + + function test_registerWiring_emits_and_stores() public { + vm.expectEmit(true, false, false, true); + emit WiringRegistered(asset, address(1), address(2), bytes32("x")); + vm.prank(operator); + element.registerWiring(asset, address(1), address(2), bytes32("x")); + + (bool registered, address regIr, address regMc, bytes32 hash) = element.wiringOf(asset); + assertTrue(registered); + assertEq(regIr, address(1)); + assertEq(regMc, address(2)); + assertEq(hash, bytes32("x")); + } + + function test_live_happy_path_passes() public { + address tokenAddr = _standUpLiveToken(); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_live_drift_identityRegistry_code2() public { + address tokenAddr = _standUpLiveToken(); + token.setIdentityRegistry(address(0xBAD)); // token now reports a different IR + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + function test_live_drift_compliance_code2() public { + address tokenAddr = _standUpLiveToken(); + token.setCompliance(address(0xBAD)); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + function test_live_drift_codehash_code2() public { + address tokenAddr = _standUpLiveToken(); + // Re-register with a wrong expected codehash => implementation drift. + vm.prank(operator); + element.registerWiring(tokenAddr, address(ir), address(mc), bytes32("wrong-codehash")); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(2)); + } + + function test_live_paused_code3() public { + address tokenAddr = _standUpLiveToken(); + token.setPaused(true); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(3)); + } + + function test_live_frozen_seller_code4() public { + address tokenAddr = _standUpLiveToken(); + token.setFrozen(counterparty, true); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(4)); + } + + function test_live_frozen_buyer_code4() public { + address tokenAddr = _standUpLiveToken(); + token.setFrozen(user, true); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(4)); + } + + function test_live_free_balance_exactly_equal_passes() public { + address tokenAddr = _standUpLiveToken(); + token.setBalance(counterparty, 1000); + token.setFrozenTokens(counterparty, 500); // free = 500 + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertTrue(passed); // INCLUSIVE >= : exactly-equal free balance passes + assertEq(reasonCode, bytes32(0)); + } + + function test_live_free_balance_one_short_code5() public { + address tokenAddr = _standUpLiveToken(); + token.setBalance(counterparty, 1000); + token.setFrozenTokens(counterparty, 501); // free = 499, need 500 + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(5)); + } + + function test_live_amount_zero_skips_balance_leg() public { + address tokenAddr = _standUpLiveToken(); + // Seller has zero free balance, but amount 0 has nothing to clear => passes. + token.setBalance(counterparty, 0); + token.setFrozenTokens(counterparty, 0); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_live_unverified_buyer_code6() public { + address tokenAddr = _standUpLiveToken(); + ir.setVerified(user, false); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(6)); + } + + function test_live_canTransfer_false_code6() public { + address tokenAddr = _standUpLiveToken(); + mc.setAllow(false); // compliance module rejects + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(6)); + } + + function test_live_eoa_target_code1() public { + // Attest + register wiring for an EOA (no code) asset => standard mismatch. + address eoa = address(0xE0A); + vm.startPrank(operator); + element.setErc3643Native(eoa, true); + element.registerWiring(eoa, address(1), address(2), bytes32(0)); + vm.stopPrank(); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, eoa, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(1)); + } + + function test_live_reverting_target_code1() public { + RevertingToken rt = new RevertingToken(); + address tokenAddr = address(rt); + vm.startPrank(operator); + element.setErc3643Native(tokenAddr, true); + element.registerWiring(tokenAddr, address(1), address(2), tokenAddr.codehash); + vm.stopPrank(); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); // first probe (identityRegistry()) reverts => fail-closed + assertEq(reasonCode, _code(1)); + } + + function test_clearWiring_returns_to_declaration_only() public { + address tokenAddr = _standUpLiveToken(); + // Break a live gate (pause) so live-regime check would fail with code 3... + token.setPaused(true); + (bool pausedFail, bytes32 rc3) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(pausedFail); + assertEq(rc3, _code(3)); + + // ...then clear the wiring: back to declaration-only, attested => PASS. + vm.expectEmit(true, false, false, false); + emit WiringCleared(tokenAddr); + vm.prank(operator); + element.clearWiring(tokenAddr); + + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + + (bool registered,,,) = element.wiringOf(tokenAddr); + assertFalse(registered); + } + + function test_clearWiring_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + element.clearWiring(asset); + } + + function test_live_unattested_still_fails_code1_even_with_wiring() public { + // Wiring registered but declaration NOT attested => declaration check (①) + // fires first => code 1. + address tokenAddr = _standUpLiveToken(); + vm.prank(operator); + element.setErc3643Native(tokenAddr, false); + (bool passed, bytes32 reasonCode) = element.check(user, counterparty, tokenAddr, 500, ""); + assertFalse(passed); + assertEq(reasonCode, _code(1)); + } } diff --git a/test/unit/compliance/elements/IdentityUniqueness.t.sol b/test/unit/compliance/elements/IdentityUniqueness.t.sol index d910de9..982a45d 100644 --- a/test/unit/compliance/elements/IdentityUniqueness.t.sol +++ b/test/unit/compliance/elements/IdentityUniqueness.t.sol @@ -12,6 +12,7 @@ import { Statefulness } from "../../../../src/types/ComplianceTypes.sol"; import {Errors} from "../../../../src/libraries/Errors.sol"; +import {ReasonCodes} from "../../../../src/libraries/ReasonCodes.sol"; contract IdentityUniquenessTest is Test { // Re-declared with identical signatures to IdentityUniqueness's own events so @@ -19,6 +20,17 @@ contract IdentityUniquenessTest is Test { // Contract.Event(...)` syntax for events declared on another contract. event IdentityBound(address indexed wallet, bytes32 indexed identityId); event IdentityUnbound(address indexed wallet, bytes32 indexed identityId); + event KycClaimSet( + bytes32 indexed identityId, + bool exists, + bool signatureValid, + bool issuerTrusted, + uint64 verifiedAt, + uint64 maxAge + ); + event IdentityStatusSet(bytes32 indexed identityId, IdentityUniqueness.IdentityStatus status); + event DedupStatusSet(bytes32 indexed identityId, IdentityUniqueness.DedupStatus status); + event EnforceCounterpartySet(bool enabled); IdentityUniqueness internal id; @@ -153,4 +165,381 @@ contract IdentityUniquenessTest is Test { vm.expectRevert(IdentityUniqueness.IdentityBindingConflict.selector); id.bindIdentity(wallet, bytes32(0)); } + + // ===================================================================== + // A-04 walkthrough-spec upgrade (doc §5.2/§5.3/§6.2): KYC claim pipeline, + // identity status, dedup status, opt-in counterparty gate. + // ===================================================================== + + function code(uint32 n) internal pure returns (bytes32) { + return ReasonCodes.encode(0, bytes32("A-04-v1"), n); + } + + function bind(address w) internal { + vm.prank(operator); + id.bindIdentity(w, identityId); + } + + function claimOf(bytes32 idty) internal view returns (IdentityUniqueness.KycClaim memory c) { + (c.exists, c.signatureValid, c.issuerTrusted, c.verifiedAt, c.maxAge) = id.kycClaimOf(idty); + } + + function setClaim(bytes32 idty, bool exists, bool sig, bool trusted, uint64 verifiedAt, uint64 maxAge) internal { + vm.prank(operator); + id.setKycClaim( + idty, + IdentityUniqueness.KycClaim({ + exists: exists, signatureValid: sig, issuerTrusted: trusted, verifiedAt: verifiedAt, maxAge: maxAge + }) + ); + } + + // --- legacy code-1 meaning + bind seeding invariant --- + + function test_check_unbound_returnsCode1() public view { + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(1)); + } + + function test_bindIdentity_seedsValidKycClaim() public { + vm.warp(1000); + bind(wallet); + + IdentityUniqueness.KycClaim memory c = claimOf(identityId); + assertTrue(c.exists); + assertTrue(c.signatureValid); + assertTrue(c.issuerTrusted); + assertEq(c.verifiedAt, 1000); + assertEq(c.maxAge, 0); + } + + function test_bindIdentity_doesNotOverwrite_operatorCustomizedClaim() public { + bind(wallet); + setClaim(identityId, true, false, true, uint64(block.timestamp), 0); + + // Idempotent rebind must not clobber the customized (exists=true) claim. + bind(wallet); + IdentityUniqueness.KycClaim memory c = claimOf(identityId); + assertFalse(c.signatureValid); + } + + function test_bindIdentity_reseedsClaim_whenClaimMarkedNonexistent() public { + bind(wallet); + setClaim(identityId, false, false, false, 0, 0); + vm.prank(operator); + id.unbindIdentity(wallet); + + // Re-bind must restore the legacy guarantee: bound => passes. + bind(wallet); + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_boundOnlyWallet_passes_withDefaults() public { + bind(wallet); + // Defaults: seeded claim + ACTIVE + UNIQUE (enum zero) — legacy compat. + assertEq(uint256(id.identityStatusOf(identityId)), uint256(IdentityUniqueness.IdentityStatus.ACTIVE)); + assertEq(uint256(id.dedupStatusOf(identityId)), uint256(IdentityUniqueness.DedupStatus.UNIQUE)); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + // --- setter auth for the new operator setters --- + + function test_setKycClaim_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + id.setKycClaim( + identityId, + IdentityUniqueness.KycClaim({ + exists: true, signatureValid: true, issuerTrusted: true, verifiedAt: 0, maxAge: 0 + }) + ); + } + + function test_setIdentityStatus_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + } + + function test_setDedupStatus_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.CONFIRMED_DUPLICATE); + } + + function test_setEnforceCounterparty_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + id.setEnforceCounterparty(true); + } + + // --- setter events --- + + function test_setKycClaim_emitsEvent() public { + vm.expectEmit(true, false, false, true); + emit KycClaimSet(identityId, true, false, true, 123, 456); + setClaim(identityId, true, false, true, 123, 456); + } + + function test_setIdentityStatus_emitsEvent_andStoresState() public { + vm.expectEmit(true, false, false, true); + emit IdentityStatusSet(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + assertEq(uint256(id.identityStatusOf(identityId)), uint256(IdentityUniqueness.IdentityStatus.FROZEN)); + } + + function test_setDedupStatus_emitsEvent_andStoresState() public { + vm.expectEmit(true, false, false, true); + emit DedupStatusSet(identityId, IdentityUniqueness.DedupStatus.SUSPECTED_DUPLICATE); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.SUSPECTED_DUPLICATE); + assertEq(uint256(id.dedupStatusOf(identityId)), uint256(IdentityUniqueness.DedupStatus.SUSPECTED_DUPLICATE)); + } + + function test_setEnforceCounterparty_emitsEvent_andStoresState() public { + vm.expectEmit(false, false, false, true); + emit EnforceCounterpartySet(true); + vm.prank(operator); + id.setEnforceCounterparty(true); + assertTrue(id.enforceCounterparty()); + } + + // --- pipeline failure codes 2..9 --- + + function test_check_code2_whenClaimMissing() public { + bind(wallet); + setClaim(identityId, false, false, false, 0, 0); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(2)); + } + + function test_check_code3_whenSignatureInvalid() public { + bind(wallet); + setClaim(identityId, true, false, true, uint64(block.timestamp), 0); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(3)); + } + + function test_check_code4_whenIssuerUntrusted() public { + bind(wallet); + setClaim(identityId, true, true, false, uint64(block.timestamp), 0); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(4)); + } + + function test_check_code5_whenClaimExpired() public { + vm.warp(1000); + bind(wallet); + setClaim(identityId, true, true, true, 1000, 100); + + vm.warp(1101); // age 101 > maxAge 100 + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(5)); + } + + function test_check_freshnessBoundary_exactlyMaxAge_passes() public { + vm.warp(1000); + bind(wallet); + setClaim(identityId, true, true, true, 1000, 100); + + vm.warp(1100); // age 100 == maxAge — strict `>` discipline: still fresh + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_check_maxAgeZero_neverExpires() public { + vm.warp(1000); + bind(wallet); + + vm.warp(1000 + 3650 days); + (bool passed,) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + } + + // A future verifiedAt anchor must not underflow the age subtraction and + // panic — age is treated as 0 (fresh), so `check` PASSes even with a + // nonzero maxAge (mirrors QualifiedPurchaser's freshness guard). + function test_check_futureVerifiedAt_passesWithoutRevert() public { + vm.warp(1000); + bind(wallet); + setClaim(identityId, true, true, true, 5000, 100); // anchor 4000s in the future + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_check_code6_whenIdentityFrozen() public { + bind(wallet); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(6)); + } + + function test_check_code7_whenIdentityRevoked() public { + bind(wallet); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.REVOKED); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(7)); + } + + function test_check_passesAgain_afterUnfreeze() public { + bind(wallet); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.ACTIVE); + + (bool passed,) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + } + + function test_check_code8_whenConfirmedDuplicate() public { + bind(wallet); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.CONFIRMED_DUPLICATE); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(8)); + } + + // Doc §6.2: SUSPECTED is REVIEW (not FAIL) off-chain; on-chain the element + // has only pass/fail, so the distinction lives in the code number (9 vs 8). + function test_check_code9_whenSuspectedDuplicate() public { + bind(wallet); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.SUSPECTED_DUPLICATE); + + (bool passed, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(9)); + } + + function test_check_passesAgain_afterSuspicionClearedToUnique() public { + bind(wallet); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.SUSPECTED_DUPLICATE); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.UNIQUE); + + (bool passed,) = id.check(wallet, address(0), asset, 0, ""); + assertTrue(passed); + } + + // --- check-order precedence (doc §5.2: earlier stage wins) --- + + function test_checkOrder_invalidSig_beatsUntrustedIssuer() public { + bind(wallet); + setClaim(identityId, true, false, false, uint64(block.timestamp), 0); + + (, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertEq(rc, code(3)); + } + + function test_checkOrder_expired_beatsFrozen() public { + vm.warp(1000); + bind(wallet); + setClaim(identityId, true, true, true, 1000, 10); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + + vm.warp(2000); + (, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertEq(rc, code(5)); + } + + function test_checkOrder_frozen_beatsConfirmedDuplicate() public { + bind(wallet); + vm.prank(operator); + id.setIdentityStatus(identityId, IdentityUniqueness.IdentityStatus.FROZEN); + vm.prank(operator); + id.setDedupStatus(identityId, IdentityUniqueness.DedupStatus.CONFIRMED_DUPLICATE); + + (, bytes32 rc) = id.check(wallet, address(0), asset, 0, ""); + assertEq(rc, code(6)); + } + + // --- opt-in counterparty gate --- + + function test_counterpartyGate_offByDefault_unboundCounterpartyPasses() public { + bind(wallet); + assertFalse(id.enforceCounterparty()); + + // e.g. an AMM pool address with no identity — must not block the trade. + (bool passed, bytes32 rc) = id.check(wallet, otherWallet, asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_counterpartyGate_on_unboundCounterparty_failsCode1() public { + bind(wallet); + vm.prank(operator); + id.setEnforceCounterparty(true); + + (bool passed, bytes32 rc) = id.check(wallet, otherWallet, asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(1)); + } + + function test_counterpartyGate_on_bothBound_passes() public { + bind(wallet); + vm.prank(operator); + id.bindIdentity(otherWallet, otherIdentityId); + vm.prank(operator); + id.setEnforceCounterparty(true); + + (bool passed, bytes32 rc) = id.check(wallet, otherWallet, asset, 0, ""); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function test_counterpartyGate_on_counterpartyPipelineCodePropagates() public { + bind(wallet); + vm.prank(operator); + id.bindIdentity(otherWallet, otherIdentityId); + vm.prank(operator); + id.setDedupStatus(otherIdentityId, IdentityUniqueness.DedupStatus.CONFIRMED_DUPLICATE); + vm.prank(operator); + id.setEnforceCounterparty(true); + + (bool passed, bytes32 rc) = id.check(wallet, otherWallet, asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(8)); + } + + function test_counterpartyGate_userFailure_takesPrecedence_overCounterparty() public { + // user unbound(1), counterparty bound-but-frozen(6): user leg runs first. + vm.prank(operator); + id.bindIdentity(otherWallet, otherIdentityId); + vm.prank(operator); + id.setIdentityStatus(otherIdentityId, IdentityUniqueness.IdentityStatus.FROZEN); + vm.prank(operator); + id.setEnforceCounterparty(true); + + (bool passed, bytes32 rc) = id.check(wallet, otherWallet, asset, 0, ""); + assertFalse(passed); + assertEq(rc, code(1)); + } } diff --git a/test/unit/compliance/elements/QualifiedPurchaser.t.sol b/test/unit/compliance/elements/QualifiedPurchaser.t.sol new file mode 100644 index 0000000..e198a93 --- /dev/null +++ b/test/unit/compliance/elements/QualifiedPurchaser.t.sol @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {QualifiedPurchaser} from "../../../../src/compliance/elements/QualifiedPurchaser.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import {LookThroughStatus} from "../../../../src/interfaces/compliance/ILookThroughSource.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../../../src/types/ComplianceTypes.sol"; + +contract QualifiedPurchaserTest is Test { + // Re-declared for vm.expectEmit; enum params canonicalize to uint8 in the + // event signature (Solidity 0.8.17 cannot `emit` a contract's event by + // qualified name). + event QualifiedPurchaserSet(address indexed investor, bool isQp); + event QpClaimSet( + address indexed investor, + uint8 basis, + bool signatureValid, + bool issuerTrusted, + uint64 verifiedAt, + uint8 ltStatus, + bytes32 coveredCompany + ); + event FreshnessCapSet(uint64 cap); + + address internal user = address(0xB0B); + address internal asset = address(0xA55E7); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + QualifiedPurchaser internal qpElement; + + function setUp() public { + // Warp well past the freshness cap so freshness math is exercised for + // real (default foundry timestamp of 1 would mask underflow guards). + vm.warp(1_000 days); + qpElement = new QualifiedPurchaser(); + } + + // --- helpers --------------------------------------------------------- + + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("A-13-v1"), uint32(n))); + } + + function _claim(QualifiedPurchaser.QpBasis basis, LookThroughStatus lt, bytes32 covered) + internal + view + returns (QualifiedPurchaser.QpClaim memory) + { + return QualifiedPurchaser.QpClaim({ + basis: basis, + signatureValid: true, + issuerTrusted: true, + verifiedAt: uint64(block.timestamp), + ltStatus: lt, + coveredCompany: covered + }); + } + + function _set(QualifiedPurchaser.QpClaim memory c) internal { + qpElement.setQpClaim(user, c); + } + + function _check() internal view returns (bool passed, bytes32 reasonCode) { + return qpElement.check(user, 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)); + } + + function _fundKey(address a) internal pure returns (bytes32) { + return bytes32(uint256(uint160(a))); + } + + // --- metadata -------------------------------------------------------- + + function test_metadata() public { + ElementMetadata memory m = qpElement.elementMetadata(); + assertEq(m.elementId, bytes32("A-13-v1")); + assertEq(uint256(m.decidability), uint256(Decidability.ATTESTATION_BASED)); + assertEq(uint256(m.category), uint256(ElementCategory.INVESTOR_ATTRIBUTE)); + assertEq(uint256(m.statefulness), uint256(Statefulness.STATELESS)); + } + + function test_default_freshness_cap_is_one_year() public { + assertEq(qpElement.freshnessCap(), 365 days); + } + + // --- legacy setQp compatibility -------------------------------------- + + function test_legacy_setQp_true_passes_and_qp_view_true() public { + (bool passed,) = _check(); + assertFalse(passed); // no claim yet + assertFalse(qpElement.qp(user)); + + qpElement.setQp(user, true); + _assertPass(); + assertTrue(qpElement.qp(user)); + } + + function test_legacy_setQp_false_clears() public { + qpElement.setQp(user, true); + assertTrue(qpElement.qp(user)); + + qpElement.setQp(user, false); + assertFalse(qpElement.qp(user)); + _assertFail(1); + } + + function test_legacy_setQp_emits() public { + vm.expectEmit(true, false, false, true); + emit QualifiedPurchaserSet(user, true); + qpElement.setQp(user, true); + } + + function test_setQp_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + qpElement.setQp(user, true); + } + + function test_operator_can_set() public { + qpElement.setOperator(operator, true); + vm.prank(operator); + qpElement.setQp(user, true); + assertTrue(qpElement.qp(user)); + } + + // --- setQpClaim access + event --------------------------------------- + + function test_setQpClaim_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + _set(_claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0))); + } + + function test_setQpClaim_emits() public { + uint64 ts = uint64(block.timestamp); + vm.expectEmit(true, false, false, true); + emit QpClaimSet( + user, uint8(QualifiedPurchaser.QpBasis.QIB), true, true, ts, uint8(LookThroughStatus.NONE), bytes32(0) + ); + _set(_claim(QualifiedPurchaser.QpBasis.QIB, LookThroughStatus.NONE, bytes32(0))); + } + + function test_setFreshnessCap_reverts_for_non_operator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + qpElement.setFreshnessCap(5 * 365 days); + } + + function test_setFreshnessCap_emits_and_updates() public { + vm.expectEmit(false, false, false, true); + emit FreshnessCapSet(5 * 365 days); + qpElement.setFreshnessCap(5 * 365 days); + assertEq(qpElement.freshnessCap(), 5 * 365 days); + } + + // --- code 1: NOT_QP (existence / forgery) ---------------------------- + + function test_code1_no_claim() public { + _assertFail(1); + } + + function test_code1_basis_none_even_if_other_fields_set() public { + _set(_claim(QualifiedPurchaser.QpBasis.NONE, LookThroughStatus.COMPLETED, bytes32(0))); + _assertFail(1); + } + + function test_code1_forged_signature() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.signatureValid = false; + _set(c); + _assertFail(1); + } + + // --- code 3: UNTRUSTED_QP_CLAIM_ISSUER -------------------------------- + + function test_code3_untrusted_issuer() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.issuerTrusted = false; + _set(c); + _assertFail(3); + } + + function test_forgery_precedes_untrusted_issuer() public { + // Both invalid: signature check (code 1) runs before issuer (code 3). + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.signatureValid = false; + c.issuerTrusted = false; + _set(c); + _assertFail(1); + } + + // --- code 2: QP_CLAIM_EXPIRED (freshness, strict >) ------------------ + + function test_code2_expired_beyond_cap() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.verifiedAt = uint64(block.timestamp - 365 days - 1); // 1s past cap + _set(c); + _assertFail(2); + } + + function test_freshness_exactly_at_cap_passes() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.verifiedAt = uint64(block.timestamp - 365 days); // exactly at cap => strict > false + _set(c); + _assertPass(); + } + + function test_freshness_one_second_within_cap_passes() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.verifiedAt = uint64(block.timestamp - 365 days + 1); + _set(c); + _assertPass(); + } + + function test_issuer_check_precedes_freshness() public { + // Untrusted AND stale: issuer (3) runs before freshness (2). + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.issuerTrusted = false; + c.verifiedAt = uint64(block.timestamp - 365 days - 100); + _set(c); + _assertFail(3); + } + + function test_operator_settable_cap_extends_validity() public { + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0)); + c.verifiedAt = uint64(block.timestamp - 2 * 365 days); // stale under 1y + _set(c); + _assertFail(2); + + qpElement.setFreshnessCap(5 * 365 days); // conservative 5y option + _assertPass(); + } + + // --- direct-pass branches: NATURAL / INSTITUTIONAL / QIB ------------- + + function test_natural_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.NATURAL, LookThroughStatus.NONE, bytes32(0))); + _assertPass(); + } + + function test_institutional_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.INSTITUTIONAL, LookThroughStatus.NONE, bytes32(0))); + _assertPass(); + } + + function test_qib_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.QIB, LookThroughStatus.NONE, bytes32(0))); + _assertPass(); + } + + // --- FAMILY_COMPANY look-through branch (codes 4,5,7 + pass) --------- + + function test_family_lookthrough_none_code4() public { + _set(_claim(QualifiedPurchaser.QpBasis.FAMILY_COMPANY, LookThroughStatus.NONE, bytes32(0))); + _assertFail(4); + } + + function test_family_lookthrough_pending_code5() public { + _set(_claim(QualifiedPurchaser.QpBasis.FAMILY_COMPANY, LookThroughStatus.PENDING, bytes32(0))); + _assertFail(5); + } + + function test_family_lookthrough_failed_code7() public { + _set(_claim(QualifiedPurchaser.QpBasis.FAMILY_COMPANY, LookThroughStatus.FAILED, bytes32(0))); + _assertFail(7); + } + + function test_family_lookthrough_completed_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.FAMILY_COMPANY, LookThroughStatus.COMPLETED, bytes32(0))); + _assertPass(); + } + + // --- TRUST look-through branch (codes 4,5,6 + pass) ------------------ + + function test_trust_lookthrough_none_code4() public { + _set(_claim(QualifiedPurchaser.QpBasis.TRUST, LookThroughStatus.NONE, bytes32(0))); + _assertFail(4); + } + + function test_trust_lookthrough_pending_code5() public { + _set(_claim(QualifiedPurchaser.QpBasis.TRUST, LookThroughStatus.PENDING, bytes32(0))); + _assertFail(5); + } + + function test_trust_lookthrough_failed_code6() public { + _set(_claim(QualifiedPurchaser.QpBasis.TRUST, LookThroughStatus.FAILED, bytes32(0))); + _assertFail(6); + } + + function test_trust_lookthrough_completed_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.TRUST, LookThroughStatus.COMPLETED, bytes32(0))); + _assertPass(); + } + + function test_trust_vs_family_failed_distinction() public { + // Same FAILED look-through, different codes by basis: trust 6, family 7. + _set(_claim(QualifiedPurchaser.QpBasis.TRUST, LookThroughStatus.FAILED, bytes32(0))); + (, bytes32 trustCode) = _check(); + + _set(_claim(QualifiedPurchaser.QpBasis.FAMILY_COMPANY, LookThroughStatus.FAILED, bytes32(0))); + (, bytes32 familyCode) = _check(); + + assertEq(trustCode, _code(6)); + assertEq(familyCode, _code(7)); + assertTrue(trustCode != familyCode); + } + + // --- KNOWLEDGEABLE_EMPLOYEE branch (code 8 + pass) ------------------- + + function test_ke_matching_fund_passes() public { + _set(_claim(QualifiedPurchaser.QpBasis.KNOWLEDGEABLE_EMPLOYEE, LookThroughStatus.NONE, _fundKey(asset))); + _assertPass(); + } + + function test_ke_mismatched_fund_code8() public { + _set( + _claim(QualifiedPurchaser.QpBasis.KNOWLEDGEABLE_EMPLOYEE, LookThroughStatus.NONE, _fundKey(address(0xDEAD))) + ); + _assertFail(8); + } + + function test_ke_zero_covered_company_code8() public { + _set(_claim(QualifiedPurchaser.QpBasis.KNOWLEDGEABLE_EMPLOYEE, LookThroughStatus.NONE, bytes32(0))); + _assertFail(8); + } + + // --- OTHER branch (code 9) ------------------------------------------- + + function test_other_basis_review_code9() public { + _set(_claim(QualifiedPurchaser.QpBasis.OTHER, LookThroughStatus.NONE, bytes32(0))); + _assertFail(9); + } + + // --- check order: existence dominates everything --------------------- + + function test_expiry_not_reached_when_basis_none() public { + // basis NONE with a stale timestamp still returns code 1 (step 1 first). + QualifiedPurchaser.QpClaim memory c = + _claim(QualifiedPurchaser.QpBasis.NONE, LookThroughStatus.NONE, bytes32(0)); + c.verifiedAt = uint64(block.timestamp - 2 * 365 days); + _set(c); + _assertFail(1); + } +} diff --git a/test/unit/compliance/elements/Sanctions.t.sol b/test/unit/compliance/elements/Sanctions.t.sol new file mode 100644 index 0000000..807fa08 --- /dev/null +++ b/test/unit/compliance/elements/Sanctions.t.sol @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {Sanctions} from "../../../../src/compliance/elements/Sanctions.sol"; +import {Errors} from "../../../../src/libraries/Errors.sol"; +import {LookThroughStatus} from "../../../../src/interfaces/compliance/ILookThroughSource.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../../../src/types/ComplianceTypes.sol"; + +/// @dev A-01 Sanctions upgrade tests. Legacy wallet-only cases live in +/// test/unit/compliance/Elements.t.sol and stay green via the default-off +/// claim regime; this file covers dual-party wallet screening and the full +/// Pattern B claim pipeline (codes 1-10, doc §5.2/§6/§7). +contract SanctionsTest is Test { + // Re-declared for vm.expectEmit; the enum ltStatus param canonicalizes to + // uint8 in the signature (Solidity 0.8.17 cannot `emit` a contract event by name). + event SanctionsBlockedSet(address indexed account, bool blocked); + event ClaimRegimeSet(bool claimRegimeEnabled, bool enforceCounterpartyClaim); + event CurrentListVersionSet(uint32 version); + event ScreeningThresholdsSet(uint16 reviewThresholdBps, uint16 blockThresholdBps); + event ScreeningClaimSet( + address indexed subject, bool exists, bool isEntity, uint16 identityMatchBps, uint8 ltStatus + ); + + address internal buyer = address(0xB0B); + address internal seller = address(0x5E11E7); + address internal asset = address(0xA55E7); + address internal operator = address(0x0733); + address internal stranger = address(0xBAD); + + Sanctions internal s; + + function setUp() public { + s = new Sanctions(); + } + + // --- helpers --------------------------------------------------------- + + function _code(uint32 n) internal pure returns (bytes32) { + return keccak256(abi.encode(uint16(0), bytes32("A-01-v1"), uint32(n))); + } + + /// @dev A fully-valid claim: exists/trusted/signed, no expiry, list version 0 + /// (matches default currentListVersion), zero match score, natural person. + function _validClaim() internal pure returns (Sanctions.ScreeningClaim memory) { + return Sanctions.ScreeningClaim({ + exists: true, + issuerTrusted: true, + signatureValid: true, + expiry: 0, + screenedListVersion: 0, + identityMatchBps: 0, + isEntity: false, + ltStatus: LookThroughStatus.NONE, + blockedOwnershipBps: 0 + }); + } + + function _check() internal view returns (bool passed, bytes32 reasonCode) { + return s.check(buyer, seller, asset, 0, ""); + } + + function _assertPass() internal { + (bool passed, bytes32 rc) = _check(); + assertTrue(passed); + assertEq(rc, bytes32(0)); + } + + function _assertFail(uint32 n) internal { + (bool passed, bytes32 rc) = _check(); + assertFalse(passed); + assertEq(rc, _code(n)); + } + + // --- metadata -------------------------------------------------------- + + function test_metadata_unchanged() public { + ElementMetadata memory m = s.elementMetadata(); + assertEq(m.elementId, bytes32("A-01-v1")); + assertEq(uint256(m.category), uint256(ElementCategory.INVESTOR_ATTRIBUTE)); + 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_defaults() public { + assertFalse(s.claimRegimeEnabled()); + assertFalse(s.enforceCounterpartyClaim()); + assertEq(s.currentListVersion(), 0); + assertEq(s.reviewThresholdBps(), 7500); + assertEq(s.blockThresholdBps(), 9500); + } + + // --- auth ------------------------------------------------------------ + + function test_setBlocked_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setBlocked(buyer, true); + } + + function test_setClaim_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setClaim(buyer, _validClaim()); + } + + function test_setClaimRegime_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setClaimRegime(true, false); + } + + function test_setCurrentListVersion_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setCurrentListVersion(2); + } + + function test_setScreeningThresholds_revertsForNonOperator() public { + vm.prank(stranger); + vm.expectRevert(Errors.NotAuthorized.selector); + s.setScreeningThresholds(1000, 2000); + } + + function test_operatorCanSetClaimRegime() public { + s.setOperator(operator, true); + vm.prank(operator); + s.setClaimRegime(true, true); + assertTrue(s.claimRegimeEnabled()); + assertTrue(s.enforceCounterpartyClaim()); + } + + // --- events ---------------------------------------------------------- + + function test_setClaimRegime_emits() public { + vm.expectEmit(false, false, false, true); + emit ClaimRegimeSet(true, true); + s.setClaimRegime(true, true); + } + + function test_setCurrentListVersion_emits() public { + vm.expectEmit(false, false, false, true); + emit CurrentListVersionSet(5); + s.setCurrentListVersion(5); + } + + function test_setScreeningThresholds_emits() public { + vm.expectEmit(false, false, false, true); + emit ScreeningThresholdsSet(6000, 8000); + s.setScreeningThresholds(6000, 8000); + assertEq(s.reviewThresholdBps(), 6000); + assertEq(s.blockThresholdBps(), 8000); + } + + function test_setScreeningThresholds_revertsIfReviewAboveBlock() public { + vm.expectRevert("review>block"); + s.setScreeningThresholds(9000, 8000); + } + + function test_setClaim_emits() public { + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.identityMatchBps = 1234; + c.ltStatus = LookThroughStatus.COMPLETED; + vm.expectEmit(true, false, false, true); + emit ScreeningClaimSet(buyer, true, true, 1234, uint8(LookThroughStatus.COMPLETED)); + s.setClaim(buyer, c); + } + + // --- Pattern A: wallet screening (always on) ------------------------- + + function test_wallet_defaultPass() public { + _assertPass(); + } + + function test_wallet_userBlocked_code1() public { + s.setBlocked(buyer, true); + _assertFail(1); + } + + function test_wallet_counterpartyBlocked_code1() public { + // Buyer clean, seller in SDN set => still FAIL (both parties screened). + s.setBlocked(seller, true); + _assertFail(1); + } + + function test_wallet_unlistedCounterpartyPasses() public { + // An unlisted counterparty (e.g. AMM pool) does not block a clean buyer. + (bool passed,) = s.check(buyer, address(0xF00D), asset, 0, ""); + assertTrue(passed); + } + + function test_wallet_blockUnblockRoundTrip() public { + s.setBlocked(buyer, true); + _assertFail(1); + s.setBlocked(buyer, false); + _assertPass(); + } + + // --- regime OFF: claims ignored ------------------------------------- + + function test_regimeOff_unattestedUserPasses() public { + // No claim set, regime off (default) => wallet-only => PASS. + _assertPass(); + } + + function test_regimeOff_badClaimIgnored() public { + // Even a claim that would fail is ignored while the regime is off. + s.setClaim(buyer, _validClaim()); // exists but regime off + Sanctions.ScreeningClaim memory bad; // all-zero => exists=false + s.setClaim(seller, bad); + _assertPass(); + } + + // --- regime ON: claim pipeline codes -------------------------------- + + function _enableRegime() internal { + s.setClaimRegime(true, false); + } + + function test_code4_noClaim() public { + _enableRegime(); + _assertFail(4); + } + + function test_code5_untrustedIssuer() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.issuerTrusted = false; + s.setClaim(buyer, c); + _assertFail(5); + } + + function test_code6_invalidSignature() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.signatureValid = false; + s.setClaim(buyer, c); + _assertFail(6); + } + + function test_code7_expired_strict() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.expiry = 100; + s.setClaim(buyer, c); + vm.warp(101); // 101 > 100 => expired + _assertFail(7); + } + + function test_code7_exactlyAtExpiryPasses() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.expiry = 100; + s.setClaim(buyer, c); + vm.warp(100); // strict > : exactly at expiry is NOT expired + _assertPass(); + } + + function test_expiryZeroNeverExpires() public { + _enableRegime(); + s.setClaim(buyer, _validClaim()); // expiry 0 + vm.warp(1_000_000_000); + _assertPass(); + } + + function test_code8_staleList() public { + _enableRegime(); + s.setClaim(buyer, _validClaim()); // screenedListVersion 0 + s.setCurrentListVersion(1); // bump => claim now stale + _assertFail(8); + } + + function test_code8_matchingListVersionPasses() public { + _enableRegime(); + s.setCurrentListVersion(7); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.screenedListVersion = 7; + s.setClaim(buyer, c); + _assertPass(); + } + + function test_code2_identityBlockMatch() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.identityMatchBps = 9600; // >= blockThresholdBps (9500) + s.setClaim(buyer, c); + _assertFail(2); + } + + function test_code10_identityReviewBand() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.identityMatchBps = 8000; // [7500, 9500) => review + s.setClaim(buyer, c); + _assertFail(10); + } + + // --- name-match boundaries (doc §5.2) ------------------------------- + + function test_boundary_exactlyBlockBps_code2() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.identityMatchBps = 9500; // exactly blockThresholdBps => block (2) + s.setClaim(buyer, c); + _assertFail(2); + } + + function test_boundary_exactlyReviewBps_code10() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.identityMatchBps = 7500; // exactly reviewThresholdBps => review (10) + s.setClaim(buyer, c); + _assertFail(10); + } + + function test_boundary_justBelowReviewBps_passes() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.identityMatchBps = 7499; // below review band => PASS + s.setClaim(buyer, c); + _assertPass(); + } + + // --- entity 50%-Rule leg (codes 9, 3) ------------------------------- + + function test_code9_lookThroughPending() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.PENDING; // != COMPLETED => 9 + s.setClaim(buyer, c); + _assertFail(9); + } + + function test_code9_lookThroughNone() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.NONE; // != COMPLETED => 9 + s.setClaim(buyer, c); + _assertFail(9); + } + + function test_code3_fiftyPctRule() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.COMPLETED; + c.blockedOwnershipBps = 6000; // >= 5000 => 3 + s.setClaim(buyer, c); + _assertFail(3); + } + + function test_boundary_exactly5000Bps_code3() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.COMPLETED; + c.blockedOwnershipBps = 5000; // INCLUSIVE >= => 3 + s.setClaim(buyer, c); + _assertFail(3); + } + + function test_boundary_4999Bps_passes() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.COMPLETED; + c.blockedOwnershipBps = 4999; // below 50% => PASS + s.setClaim(buyer, c); + _assertPass(); + } + + function test_entityCompletedCleanPasses() public { + _enableRegime(); + Sanctions.ScreeningClaim memory c = _validClaim(); + c.isEntity = true; + c.ltStatus = LookThroughStatus.COMPLETED; + c.blockedOwnershipBps = 0; + s.setClaim(buyer, c); + _assertPass(); + } + + function test_regimeOn_validClaimPasses() public { + _enableRegime(); + s.setClaim(buyer, _validClaim()); + _assertPass(); + } + + // --- wallet leg precedes claim leg ---------------------------------- + + function test_walletMatchBeatsValidClaim() public { + // Even with a valid claim and regime on, an SDN wallet FAILs at code 1. + _enableRegime(); + s.setClaim(buyer, _validClaim()); + s.setBlocked(buyer, true); + _assertFail(1); + } + + // --- counterparty claim gating -------------------------------------- + + function test_counterpartyClaimSkippedWhenNotEnforced() public { + // Regime on, enforceCounterparty off: seller has NO claim but is not + // screened via the claim pipeline => PASS (buyer has a valid claim). + s.setClaimRegime(true, false); + s.setClaim(buyer, _validClaim()); + _assertPass(); + } + + function test_counterpartyClaimEnforced_missingClaim_code4() public { + // Regime on + enforceCounterparty on: seller has no claim => code 4. + s.setClaimRegime(true, true); + s.setClaim(buyer, _validClaim()); + _assertFail(4); + } + + function test_counterpartyClaimEnforced_bothValid_passes() public { + s.setClaimRegime(true, true); + s.setClaim(buyer, _validClaim()); + s.setClaim(seller, _validClaim()); + _assertPass(); + } + + function test_userClaimEvaluatedBeforeCounterparty() public { + // Both parties lack claims; the user's failure (code 4) is returned first. + s.setClaimRegime(true, true); + _assertFail(4); + } +}