diff --git a/.gitignore b/.gitignore index 0f27f46..600ce75 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ cache/ broadcast/ services/rfq/dist/ services/rfq/node_modules/ +deployments/ diff --git a/FEATURES.md b/FEATURES.md index 552c27d..0c94741 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -205,6 +205,51 @@ passing 활성화 조건 결정. - Non-goals: production legal 활성화, direction-aware element application. +## E2E-001 — Live Anvil E2E & Demo Runner + +### Behavior + +- `scripts/e2e-anvil.sh`가 fresh Anvil 노드를 띄우고 전체 스택을 배포 + (`script/DeployStack.s.sol`)한 뒤 7-scenario demo suite + (`script/DemoScenarios.s.sol`)를 구동하고, scenario별 narrative + evidence + + `PASS`/`FAIL`을 출력한 다음 노드를 teardown한다. 완전히 offline로 동작하고, + 하나라도 실패하면 스크립트가 non-zero로 종료한다. `--port`, `--keep` 플래그를 + 지원한다(`--keep`은 이후 Anvil을 계속 실행해 인터랙티브 demo/UI attach 가능). +- `DeployStack`은 registries(Element/Recipe/TokenPolicy/Operator), + ComplianceEngine, ExecutionRouter+VenueRegistry+VenueSelector, + CornerStoreFactory, 11개 element, 두 recipe(RegD 506(c) id 1, 3(c)(7) id 2) + + surveillance-enabled RegD 변형(id 7), MockPool AMM venue, RFQAdapter venue, + 그리고 REAL ERC-3643 token + OnchainID 스택을 live 노드에 배포하고 주소를 + `deployments/anvil-e2e.json`(gitignore)로 기록한다. T-REX 배포 코어는 + `test/fixtures/TREXCore.sol`로 추출해 test fixture와 script가 공유한다. +- 7 scenario: (1) factory 1-call onboarding(propose→approve+venue), (2) compliant + trade 성공, (3) live element rejection(A-02 flip → `ComplianceRejected`, off-chain + reason-code 재계산 후 복원), (4) manifest lifecycle(suspend 차단 → resume 재거래), + (5) RFQ venue(EIP-712 quote 서명 → `RFQFilled`, 미승인 maker `RFQMakerNotApproved`), + (6) surveillance(threshold 초과 시 `SurveillanceFlag`), (7) bypass 시도(direct + adapter.execute → `NotAuthorized`). +- src/(제품 코드) 변경 없음. `tools/deploy-v3`에 의존하지 않는다(vendor isolation); + AMM venue는 MockPool을 쓰고, 실제 Uniswap v3 pool 배포는 별도 follow-up으로 남는다. + +### Verification + +- `scripts/e2e-anvil.sh`(fresh + repeat) 2회 실행, 각 7/7 PASS + exit 0. +- `scripts/e2e-anvil.sh --keep` 실행 후 Anvil이 계속 살아 있음을 확인. +- `forge test --offline`(238/238 유지; TREXSuite→TREXCore 추출 후에도 green). +- `forge fmt --check`. + +### State + +passing + +### Notes + +- runbook: `docs/demo.md`(실행법, scenario 순서, reason-code 재계산, mock/real 구분). +- 관련 결정: D008/D009(illustrative element library, manifest lifecycle). 실제 + Uniswap v3 pool 배포와 production data source 연결은 out-of-scope. +- Non-goals: 실 Uniswap v3 pool, production governance key management, + direction-aware element application. + ## CMP-002 — Manifest Lifecycle & Operator Approval Flow ### Behavior diff --git a/PROGRESS.md b/PROGRESS.md index d37bb61..a59dbe8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -40,6 +40,11 @@ source of truth로 사용한다. - RFQ integration: RFQ 벤처를 protected router path(`ExecutionRouter → ComplianceEngine → RFQAdapter`)로 real ERC-3643 스택 위에서 end-to-end 커버(`test/integration/RFQFlow.t.sol`, fill/maker-unapproved/cancel/non-compliant/direct-call bypass 5 시나리오) — RFQ-002 deferred follow-up 완료. +- `E2E-001 — Live Anvil E2E & Demo Runner`(`scripts/e2e-anvil.sh` + + `script/DeployStack.s.sol` + `script/DemoScenarios.s.sol`): fresh Anvil에 전체 스택 + 배포 후 7-scenario demo suite 구동, scenario별 PASS/FAIL. T-REX 배포 코어를 + `test/fixtures/TREXCore.sol`로 추출해 fixture/script 공유. src/ 변경 없음. runbook은 + `docs/demo.md`. ## Blocked @@ -49,42 +54,48 @@ source of truth로 사용한다. 1. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 (C-01 Lockup은 현재 fixture-only mock acquisition source). -2. live Anvil deployment/E2E를 추가한다. +2. 실제 Uniswap v3 pool 배포를 demo/E2E에 연결한다(현재 AMM venue는 MockPool; + `tools/deploy-v3` vendor isolation 유지). 3. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. 4. CI hardening(static analysis 등)을 강화한다. ## Last Session Summary -- CMP-002 (Manifest Lifecycle & Operator Approval Flow)을 landing했다. Task 1 - (registry state machine)에 이어 Task 2에서 engine fail-open을 닫고 factory/seam - finalization, 통합 시나리오, bookkeeping을 완료했다. +- `E2E-001` (Live Anvil E2E & Demo Runner)을 landing했다. src/ 변경 없이 forge + script 두 개 + 셸 러너 + T-REX 배포 코어 추출로 live-Anvil E2E/demo를 구현했다. - 변경한 파일: - - product: `src/compliance/ComplianceEngine.sol`(evaluate positive-allowlist - default-deny + commit 주석), `src/registry/TokenPolicyRegistry.sol` + - `src/interfaces/compliance/ITokenPolicyRegistry.sol`(clearUnregulated), - `src/factory/CornerStoreFactory.sol`(register→approve natspec) - - test: `test/unit/compliance/Engine.t.sol`(신규 4 default-deny), - `test/unit/registry/TokenPolicyRegistry.t.sol`(신규 8 clearUnregulated+auth), - `test/integration/EmergencyPause.t.sol`(신규 3 router E2E), - `test/integration/IntegrationBase.sol`/`Surveillance.t.sol`(seam 정리) - - bookkeeping: `DECISIONS.md`(D009), `FEATURES.md`(CMP-002), `PROGRESS.md` - - Task 1(앞선 커밋): `src/types/ComplianceTypes.sol`(enum append), - `src/libraries/Errors.sol`(InvalidManifestTransition), registry state machine -- TDD: engine 4개 default-deny 테스트가 먼저 RED로 오늘의 fail-open(PROPOSED/ - RETIRED × UNREGULATED/ACTIVE가 allowed=true)을 증명한 뒤 positive-allowlist로 - GREEN 전환. + - script: `script/DeployStack.s.sol`(전체 스택 live 배포 + JSON artifact), + `script/DemoScenarios.s.sol`(7-scenario 러너), `script/DemoConstants.sol`(공유 + 상수) + - runner: `scripts/e2e-anvil.sh`(anvil 기동/배포/scenario/teardown, `--port`/ + `--keep`, offline) + - fixture refactor: `test/fixtures/TREXCore.sol`(신규, `is CommonBase`, + admin-parameterized, prank-free) + `test/fixtures/TREXSuite.sol`(thin facade + `is Test, TREXCore`) — test suite green 유지 + - config: `foundry.toml`(fs_permissions read-write, JSON artifact), + `.gitignore`(`deployments/`) + - docs: `docs/demo.md`(runbook), `docs/testing.md`(E2E 섹션), `docs/README.md` + - bookkeeping: `FEATURES.md`(E2E-001), `PROGRESS.md` +- 설계 요점: + - broadcast(pk)로 persist해야 하는 상태 전이/거래를, prank(addr)+try/catch로 + revert 기대 시나리오(compliance/policy/authz 거부)를 구동. reason code는 + off-chain 재계산 후 revert data와 비교. + - onboarding은 scenario 1에서 factory가 수행하도록 deploy 시 manifest를 UNKNOWN로 + 남기고 policyReg/venueReg 소유권을 factory로 이전, deployer는 policyReg operator로 + 남겨 lifecycle(suspend/resume/retire) 구동 가능하게 함. + - anvil genesis timestamp가 실제 wall-clock이라 C-01 Rule 144 lockup(t=1 seed)이 + on-chain에서 자연 통과 → vm.warp 불필요. - 실행한 명령: - - `forge fmt` + - `forge fmt` / `forge fmt --check` - `forge test --offline` + - `scripts/e2e-anvil.sh`(fresh + repeat, `--keep`) - 통과한 검증: - - `forge test --offline` 227/227(pre-task 212 + 신규 15). - - engine default-deny(양방향 ordering 포함), registry clearUnregulated + - onlyOwner-vs-onlyOperator auth, 통합 PROPOSED/RETIRED reject + suspend→resume - 재거래. + - `forge test --offline` 238/238(TREXCore 추출 후에도 green). + - `scripts/e2e-anvil.sh` 2회 실행 모두 7/7 PASS, exit 0. `--keep`로 anvil 잔존 확인. - 남은 리스크: - - ungated legacy mock element(A-01/A-03/QP)와 새 operator-gated element 사이 - hardening divergence — follow-up으로 정렬(CMP-001 deferred). - - C-01 Lockup은 fixture-only mock acquisition source에 의존; production + - AMM venue는 MockPool(1:1); 실제 Uniswap v3 pool 배포는 별도 follow-up(vendor + isolation). demo는 `tools/deploy-v3`에 의존하지 않는다. + - C-01 Lockup은 fixture/demo mock acquisition source에 의존; production acquisition/lot data source와 holding-period 활성화 default는 미결정. - production data source(OFAC/ONCHAINID/ERC-165/EDGAR) 연결과 legal 활성화는 approval-gated로 열려 있다. diff --git a/docs/README.md b/docs/README.md index 43915d1..698be22 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ | [`architecture/README.md`](./architecture/README.md) | 책임 레이어와 경계 인덱스 | Current | | [`ROADMAP.md`](./ROADMAP.md) | 구현 순서, 완료 조건, blocker | Current | | [`testing.md`](./testing.md) | 테스트와 완료 기준 | Current | +| [`demo.md`](./demo.md) | live Anvil E2E / demo runbook | Current | | [`security.md`](./security.md) | 보안 규칙 | Current | | [`rfq-threat-model.md`](./rfq-threat-model.md) | RFQ venue 위협 모델 | Current | | [`MVP.md`](./MVP.md) | 초기 AMM 중심 설계 기록 | Superseded | diff --git a/docs/compliance/README.md b/docs/compliance/README.md index bb1a652..c269444 100644 --- a/docs/compliance/README.md +++ b/docs/compliance/README.md @@ -16,6 +16,7 @@ | ⑥ | [Phase 1~2 실행 플랜](./06-phase-1-2-plan.md) | *그래서 지금 무엇을* 결정·구현하나? 데모 6 장면이 완성 기준 | 15분 | **전원** ⭐ | | ⑦ | [BUIDL 구현 시나리오](./07-buidl-implementation.md) | 첫 시연 자산 BUIDL을 올리려면 *구체적으로 무엇을* 만드나? — 신상카드·신규 구현 5개·거래 walkthrough | 20분 | 개발팀 + 관심 있는 분 | | ⑧ | [리걸 파트 리서치·구현 플랜](./08-legal-research-plan.md) | 검사 부품 27개를 법적으로 *어떻게 완성해서 코드로 넘기나* — 부품별 현황·리서치 큐·변환 패턴 3종 | 15분 | 리걸 협업자 + 개발팀 인수인계 담당 | +| ⑨ | [Element Data-Source Matrix](./element-data-source-matrix.md) | 구현된 부품 11개의 mock이 production에서 *어떤 데이터 소스로 무엇을 통해* 교체되나 — seam 위치·법무 확정 질문 포함 | 10분 | **개발팀 + 리걸** ⭐ | | 부속 | [개발팀 수정요청서](./appendix-change-request.md) | 우리 쪽이 요청하는 구체 변경 목록 (결정 권한은 개발팀) | — | 개발팀만 | ## 시간이 없다면 — 15분 코스 diff --git a/docs/compliance/element-data-source-matrix.md b/docs/compliance/element-data-source-matrix.md new file mode 100644 index 0000000..2847719 --- /dev/null +++ b/docs/compliance/element-data-source-matrix.md @@ -0,0 +1,55 @@ +# Element Data-Source Matrix (기술측) + +각 compliance Element의 **현재(illustrative) 구현 → production 데이터 소스 → 연동 방식 → 코드상 seam → 결정 상태**를 한 곳에 모은 변환표다. ADR-004가 Phase 1 착수 조건으로 요구하는 **Legal-to-Technical Matrix**(법 → 증거 → 집행)의 *기술측 절반*을 채우며, 각 행의 "법무 확정 질문"이 법률측 절반의 입력이 된다. + +> **원칙 (ROADMAP/MVP-v2, D008).** 모든 Element의 데이터 실질은 법률 검토 승인 전까지 +> operator-settable attestation mock으로 유지한다. 이 표의 "production 소스" 열은 +> 승인 후 연결할 대상의 후보이지, 승인된 정책이 아니다. + +> **출처 주의.** Element별 데이터 소스의 1차 도출은 전략 보고서(노트 14, +> `docs/reference/14-decipher-rwa-dex-overview.md`) §3.2인데, 이 파일은 현재 +> **main에 없다**(`layer1-layer2-mvp-for-dex` 브랜치에만 존재). main 편입 여부가 +> 미결이므로, 이 표가 main에서 참조 가능한 유일한 요약본이다. + +## 구현된 Element (11) + +| ID | 현재 mock 구현 (seam 위치) | Production 데이터 소스 | 연동 방식 | 상태 / 법무 확정 질문 | +| --- | --- | --- | --- | --- | +| **A-01 Sanctions** | operator가 `setBlocked`로 켜는 bool — `src/compliance/elements/Sanctions.sol` | OFAC SDN 리스트 (Chainalysis/TRM 류 온체인 oracle) | oracle 컨트랙트 조회로 `check` 내부 교체, 또는 oracle→attestation 갱신 봇 | 승인 게이트. Q: 리스트 갱신 SLA(거래 시점 최신성 요구 수준), 2차 제재(secondary sanctions) 포함 범위 | +| **A-02 Jurisdiction** | `jurisdictionOf` + `allowedJurisdiction` operator 매핑 — `Jurisdiction.sol` | ONCHAINID 관할권 claim (trusted issuer 발급) | claim topic 등록 + `check`가 claim 존재·유효성 조회 | 승인 게이트. Q: Reg S category 기준의 국가 분류표, allowed-set 변경 거버넌스(누가·어떤 절차로) | +| **A-03 Accredited Investor** | 단일 bool `setAccredited` — `AccreditedInvestor.sol` | ONCHAINID `ACCREDITED_INVESTOR_TOPIC` + `SELF_CERT_TOPIC` claims | **element 세분화 선행**: 노트 14 §3.1의 4-원자(적격 claim / 최소투자금액 / 자기인증 / 제3자 자금조달 부재)로 분해 후 claim별 연동 | 승인 게이트 + **구현 분해 필요**. Q: 506(c) 합리적 검증 방법(소득/순자산 증빙 기준, 2025-03 SEC No-Action Letter 반영 범위), claim 유효기간/갱신 주기 | +| **A-04 Identity Uniqueness** | operator `bindIdentity` 1:1 바인딩 (불변식은 setter가 강제) — `IdentityUniqueness.sol` | ONCHAINID IdentityRegistry (T-REX와 공유) | ERC-3643 IdentityRegistry 조회로 교체 (지갑↔identity 매핑은 이미 그쪽이 원본) | 승인 게이트. Q: 1인 다지갑 허용 정책(허용 시 한도 합산 기준), identity 회수·재발급 절차 | +| **A-05 US Tax Resident** | `setUsTaxResident` 플래그, **미플래그=통과(fail-open, 주석으로 명시)** — `UsTaxResident.sol` | 적극적 비거주 attestation (IRS Substantial Presence Test 기반) | 부재=거부(fail-closed)로 뒤집고 positive claim 요구 | 승인 게이트 + **시맨틱 반전 필요**. Q: 증빙 형식(W-8BEN 상당?), 판정 주체와 갱신 주기 | +| **A-13 Qualified Purchaser** | bool `setQualifiedPurchaser` — `QualifiedPurchaser.sol` | ONCHAINID claim | claim 존재·issuer·만료 검증 ("Pattern B") | 승인 게이트. **법률 심층 문서 존재**: `docs/compliance/elements/A-13_qualified-purchaser.md` (11개 중 유일한 walkthrough — 나머지 element의 표준 포맷) | +| **B-01 Asset Classification** | `setClassification(asset, tag)` + 생성자 `requiredClassification` — `AssetClassification.sol` | 발행인 선언 (Listing Agreement) + operator 승인 | Manifest 등록 시 분류를 함께 심사·기록하는 운영 절차와 결합 | 승인 게이트. Q: 분류 선언의 책임 주체(발행인 vs operator), 오분류 발견 시 정정·소급 절차 | +| **B-02 ERC-3643 Native** | `setErc3643Native` attestation (**의도적 stand-in**, natspec에 seam 명시) — `Erc3643Native.sol` | ERC-165 `supportsInterface`(T-REX `IToken`) 또는 token registry 조회 | `check` 내부를 introspection으로 교체 — **결정론적, 법무 불필요** | **기술 결정만 남음** (vendored T-REX의 ERC-165 지원 여부 검증 필요, D008) | +| **C-01 Rule 144 Lockup** | 주입식 `IAcquisitionSource` mock — `Lockup.sol` (test fixture에서만 활성, "CR-3" seam) | 취득 시점/lot 데이터 소스 — **미결정** (ROADMAP Decision Backlog 1순위) | acquisition registry 신설 또는 transfer-agent 피드 | **데이터 소스 자체가 open decision** — 결정 전 holding-period Recipe production 비활성이 문서화된 default. Q: lot 회계 방식(FIFO?), 데이터 원천(transfer agent? 체인 이벤트 재구성?), 6개월/1년 기간 기산점 | +| **E-01 Form D Filing** | `setFormDFiled(asset, filed, ref)` + 참조 해시 — `FormDFiling.sol` | EDGAR oracle 또는 hash-anchored Listing Agreement | 제출 확인 봇이 attestation 갱신 + `filingRef`에 accession number 해시 | 승인 게이트. Q: 최초 판매 후 15일 제출 시한의 온체인 반영(유예 처리), amendment 추적 범위 | +| **F-02 Market Conduct (Surveillance)** | 거래 카운터 임계 초과 시 flag 이벤트 (STATEFUL, 차단 안 함) — `SurveillanceFlag.sol` | Phase 3 operator 시장감시 규칙 | 감시 패턴은 off-chain 분석 + on-chain flag hook 유지 | **Phase 3 범위** (Layer 4). Q: 감시 패턴 정의와 broker-dealer 규제 연구 결과 대기 (Element catalog freeze 노트 참조) | + +## 백로그 Element (미구현 — pool freeze v1 기준) + +| ID | 내용 | 데이터 소스 후보 | 상태 | +| --- | --- | --- | --- | +| C-02 Swap Cooldown | 스왑 간 최소 간격 | on-chain state (자체 기록) | Phase 1·2 optional | +| C-03 Max Balance per Holder | 보유 한도 | on-chain state + manifest 파라미터 | Phase 1·2 optional | +| E-02 Issuer Standing | 발행인 자격 유지 | issuer attestation | Phase 1·2 optional | +| D-03 Listing Differential Disclosure | 상장 차등 공시 | operator/issuer 공시 채널 | Phase 3 | +| F-01 Operator Affiliate Restriction | 운영자 특수관계인 제한 | operator 신고 | Phase 3 | + +41개 확장 카탈로그(법률 리서치 제안)는 backlog 입력이며, market-conduct/broker-dealer +연구 완료 전 개별 Element의 법적 정확성·데이터 소스는 검토되지 않은 것으로 본다 +(`docs/compliance/04-element-interface.md` 배너, MVP-v2 §5·§8). + +## 다음 단계 + +1. **법무측 절반**: 이 표의 "법무 확정 질문" 열을 입력으로 Legal-to-Technical Matrix의 + 법률 검증(ADR-004 요구 산출물)을 완성한다. Element당 심층 포맷은 A-13 walkthrough를 + 표준으로 한다. +2. **기술 선행 작업** (법무 무관하게 진행 가능): B-02 introspection 전환 검증, + A-03 4-원자 element 분해 설계, A-05 fail-closed 반전 설계. +3. **결정 대기**: C-01 acquisition 데이터 소스 (ROADMAP Decision Backlog). + +관련 문서: D008/D009(`DECISIONS.md`), `docs/demo.md`(mock vs real 경계), +`docs/compliance/04-element-interface.md`(인터페이스·택소노미), +`docs/compliance/elements/A-13_qualified-purchaser.md`(walkthrough 표준). diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..59a6656 --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,118 @@ +# Demo Runbook — Live Anvil E2E + +The live-Anvil runner deploys the FULL Corner Store stack to a real node and +drives a 7-scenario suite that doubles as the stakeholder demo. Each scenario +prints a one-line narrative, its observable on-chain evidence, and `PASS`/`FAIL`, +so a non-engineer can watch the 4-layer compliance model (Element / Recipe / +Manifest / Operator) work end-to-end. + +This is feature `E2E-001` (see `FEATURES.md`). + +## How To Run + +```sh +scripts/e2e-anvil.sh # start Anvil, deploy, run scenarios, tear down +scripts/e2e-anvil.sh --port 8600 +scripts/e2e-anvil.sh --keep # leave Anvil running afterwards (attach a UI / continue interactively) +``` + +- Runs fully offline (`forge script ... --offline` against a local Anvil). +- Exit code is non-zero if any scenario fails (`DemoScenarios` reverts). +- `--keep` prints the Anvil PID and RPC URL and leaves the node up; stop it with + `kill `. + +Under the hood the runner executes two forge scripts: + +1. `script/DeployStack.s.sol` — deploys registries (Element/Recipe/TokenPolicy/ + Operator), `ComplianceEngine`, `ExecutionRouter` + `VenueRegistry` + + `VenueSelector`, `CornerStoreFactory`, all 11 elements, both recipes (Reg D + 506(c) id 1, 3(c)(7) id 2) plus a surveillance-enabled RegD variant (id 7), + the AMM `MockPool` venue, the `RFQAdapter` venue, and a REAL ERC-3643 token + + OnchainID stack (via the shared `test/fixtures/TREXCore.sol`). It prints an + address summary and writes it to `deployments/anvil-e2e.json` (gitignored). +2. `script/DemoScenarios.s.sol` — reads that artifact and runs the suite below. + +Accounts are Anvil's well-known mnemonic (`test test ... junk`): +account 0 = deployer/owner/operator, 1 = investor (buyer/taker), +2 = RFQ maker (approved dealer), 3 = unapproved maker. + +## Scenario Narrative (in order) + +1. **Onboarding** — `CornerStoreFactory.registerRWAToken` onboards the RWA token + in one governed call: the manifest runs `propose -> approve` and the AMM venue + is registered. Evidence: `ManifestRegistered` / `ManifestStatusChanged` events, + manifest status `ACTIVE`, `declaredBy == approvedBy == factory`. +2. **Compliant trade succeeds** — the fully-attested investor buys RWA through the + router → AMM path. Evidence: `Executed` event + investor RWA balance delta. +3. **Element rejection, live** — the operator flips ONE attestation (investor + jurisdiction A-02 → a disallowed code). The same trade now reverts + `ComplianceRejected`. The runner recomputes the expected reason code + off-chain and asserts equality, then narrates "rejected by A-02 Jurisdiction". + The attestation is restored afterward. +4. **Lifecycle** — `suspendManifest` blocks the trade (`ComplianceRejected`, + policy not `ACTIVE`); `resumeManifest` lets it settle again. +5. **RFQ venue** — the maker signs an EIP-712 quote off-chain (in-script signing + with a known key); the taker settles it through the router (`RFQFilled`, real + ERC-3643 delivery leg). Then an UNAPPROVED maker's quote is rejected + (`RFQMakerNotApproved`). +6. **Surveillance (stateful layer)** — the operator re-onboards the RWA under a + surveillance-enabled recipe (id 7 = RegD 9-element set + F-02), then repeated + trades push the transfer counter past the threshold, emitting a + `SurveillanceFlag` event. Surveillance is flag-not-block: the trades still + settle. +7. **Bypass attempt** — a direct `adapter.execute` call (going around the router) + reverts `NotAuthorized`: compliance cannot be skipped by bypassing the router. + +## Reading Reason Codes + +`ComplianceRejected(bytes32 reasonCode)` carries a `keccak256` digest, not a +human-readable code — element ids are full `bytes32`, so they cannot be +bit-packed into one word and the digest is not on-chain-decodable (see +`src/libraries/ReasonCodes.sol`). Off-chain audit (17a-3/4) recomputes the known +`(recipeId, elementId, code)` combination and matches it: + +```solidity +ReasonCodes.encode(recipeId, elementId, code) == keccak256(abi.encode(recipeId, elementId, code)) +``` + +Scenario 3 does exactly this: it recomputes `ReasonCodes.encode(1, "A-02-v1", 1)` +(Reg D recipe id 1, jurisdiction element, generic fail code 1) and asserts it +equals the `reasonCode` decoded from the caught revert. The engine re-encodes +each element's placeholder code with the real contributing `recipeId`, so the +digest is stable and reproducible. + +A policy-level rejection (scenario 4, suspended manifest) uses +`ReasonCodes.encode(0, "POLICY", uint32(status))` where `status` is the offending +`PolicyStatus` (SUSPENDED = 3). + +## Mock vs Real + +REAL, genuinely enforced on-chain: + +- The RWA token is a real **ERC-3643 (T-REX)** token with a real **OnchainID** + identity registry, trusted claim issuer, and KYC claims. `isVerified` and + `canTransfer` are honoured on every RWA leg (verified-holder transfers, minting + to verified holders only, real rollback on unverified recipients). +- The full compliance engine, recipes, elements, router, venue selector, + manifest lifecycle, RFQ EIP-712 verification, and per-caller nonce replay guard + are the production skeleton contracts, unmodified. + +MOCK / illustrative (documented seams): + +- The AMM venue is the in-repo `MockPool` (1:1 rate), **not** a real Uniswap v3 + pool. A real Uniswap v3 pool deployment is a separate follow-up: the vendored + `tools/deploy-v3` infrastructure is kept isolated (vendor-isolation rule) and + the demo does not depend on it. See `tools/deploy-v3/CORNER_STORE_PROFILE.md`. +- Element data sources (OFAC / ONCHAINID claims / ERC-165 / EDGAR) are + operator-settable mocks, and the C-01 Rule 144 lockup reads an injected + acquisition-time source. These illustrative wirings and the manifest lifecycle + design are recorded in `DECISIONS.md` **D008** (9-element recipe, operator-gated + setters, fixture acquisition source) and **D009** (manifest lifecycle state + machine, engine positive-allowlist default-deny, factory register→approve). +- `QUOTE` is a plain `MockERC20` tagged `UNREGULATED` (out-of-scope cash leg). + +## Related + +- Test layers and the automated suite: `docs/testing.md`. +- Architecture and trust boundaries: `ARCHITECTURE.md`, `docs/architecture/`. +- Decisions: `DECISIONS.md` (D008, D009). diff --git a/docs/testing.md b/docs/testing.md index 0a8cfb3..7b4943c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -49,11 +49,24 @@ multi-Recipe, surveillance, emergency pause와 invariant path를 검증한다. `tools/deploy-v3`의 Corner Store profile은 unit test로 구성과 순서를 검증하며, 과거 수동 Anvil 배포 검증 기록은 `tools/deploy-v3/CORNER_STORE_PROFILE.md`에 있다. -아직 자동화된 live Anvil deployment/E2E는 없다. +자동화된 live Anvil deployment/E2E는 `scripts/e2e-anvil.sh`로 제공된다(아래 E2E +Tests 및 `docs/demo.md` 참조). ### E2E Tests -아직 제품 E2E test가 없다. 향후 최소 E2E는 다음을 포함해야 한다. +live Anvil E2E는 `scripts/e2e-anvil.sh`로 자동화되어 있다(feature `E2E-001`). +이 러너는 fresh Anvil 노드에 전체 스택을 배포(`script/DeployStack.s.sol`)하고 +7-scenario demo suite를 구동(`script/DemoScenarios.s.sol`)하며, scenario별 +narrative + 관찰 가능한 evidence + `PASS`/`FAIL`을 출력한다. 하나라도 실패하면 +스크립트가 non-zero로 종료한다. 실행 방법과 scenario 순서, reason code 재계산, +mock/real 구분은 `docs/demo.md`(demo runbook)를 참조한다. + +```sh +scripts/e2e-anvil.sh # 배포 → scenario → teardown (offline) +scripts/e2e-anvil.sh --keep # 이후 Anvil을 계속 실행(인터랙티브 demo) +``` + +이 러너가 커버하는 최소 E2E는 다음을 포함한다. - 허용된 거래의 실행 성공 - applicable Recipe 중 하나의 Element 거부에 따른 원자적 실패 diff --git a/foundry.toml b/foundry.toml index 7019fbb..953e38e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,4 +6,4 @@ solc = "0.8.17" optimizer = true optimizer_runs = 200 via_ir = true -fs_permissions = [{ access = "read", path = "./" }] +fs_permissions = [{ access = "read-write", path = "./deployments" }] diff --git a/script/DemoConstants.sol b/script/DemoConstants.sol new file mode 100644 index 0000000..653c59e --- /dev/null +++ b/script/DemoConstants.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +/// @title DemoConstants +/// @notice Shared, deterministic constants for the live-Anvil E2E demo scripts +/// ({DeployStack} and {DemoScenarios}). Centralised so the deploy and the +/// scenario runner never drift on accounts, amounts, or the artifact path. +abstract contract DemoConstants { + // Anvil's well-known development mnemonic. Accounts: + // 0 = deployer (owner/operator) 1 = investor (buyer/taker) + // 2 = RFQ maker (approved dealer) 3 = unapproved maker + string internal constant MNEMONIC = "test test test test test test test test test test test junk"; + + // Deterministic RFQ venue label (non-custodial: target/operator zero). + address internal constant RFQ_VENUE = 0x000000000000000000000000000000000000F00D; + + // Reg D 506(c) fixture facts. + bytes32 internal constant ALLOWED_JURISDICTION = bytes32("US"); + bytes32 internal constant REG_D_CLASS = bytes32("REG_D"); + uint64 internal constant LOCKUP_SECONDS = 365 days; + + // supportedEngines / allowedVenueTypes bits (indexed by VenueType value). + uint8 internal constant ENGINES_AMM = uint8(1 << 0); // VenueType.AMM + uint8 internal constant ENGINES_RFQ = uint8(1 << 2); // VenueType.RFQ + + // Surveillance-enabled RegD recipe id (deployed by DeployStack, used by + // scenario 6). Distinct from the base RegD 506(c) recipe (id 1) so the + // scenario-3 rejection reason code stays ReasonCodes.encode(1, "A-02-v1", 1). + uint16 internal constant SURVEIL_RECIPE_ID = 7; + + // Demo amounts. + uint256 internal constant INVESTOR_QUOTE = 5_000 ether; + uint256 internal constant MAKER_RWA = 1_000 ether; + uint256 internal constant POOL_RWA = 5_000 ether; + uint256 internal constant AMM_TRADE = 100 ether; // QUOTE in == RWA out (1:1 pool) + uint256 internal constant RFQ_QUOTE_IN = 120 ether; + uint256 internal constant RFQ_RWA_OUT = 200 ether; + + // Deployment artifact shared between the two scripts. + string internal constant ARTIFACT_PATH = "deployments/anvil-e2e.json"; +} diff --git a/script/DemoScenarios.s.sol b/script/DemoScenarios.s.sol new file mode 100644 index 0000000..7257665 --- /dev/null +++ b/script/DemoScenarios.s.sol @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {CornerStoreFactory} from "../src/factory/CornerStoreFactory.sol"; +import {TokenPolicyRegistry} from "../src/registry/TokenPolicyRegistry.sol"; +import {Jurisdiction} from "../src/compliance/elements/Jurisdiction.sol"; +import {SurveillanceFlag} from "../src/compliance/elements/SurveillanceFlag.sol"; +import {ExecutionRouter} from "../src/execution/ExecutionRouter.sol"; +import {UniswapV3Adapter} from "../src/execution/adapters/amm/UniswapV3Adapter.sol"; +import {RFQAdapter} from "../src/execution/adapters/rfq/RFQAdapter.sol"; +import {RFQQuote} from "../src/execution/adapters/rfq/RFQTypes.sol"; + +import {ExecutionRequest} from "../src/types/ExecutionTypes.sol"; +import { + ComplianceContext, + ComplianceDecision, + ManifestCore, + PolicyStatus, + VenueType, + FlowType +} from "../src/types/ComplianceTypes.sol"; +import {VenueConfig, CustodyModel} from "../src/types/VenueTypes.sol"; +import {Errors} from "../src/libraries/Errors.sol"; +import {ReasonCodes} from "../src/libraries/ReasonCodes.sol"; +import {DemoConstants} from "./DemoConstants.sol"; + +/// @title DemoScenarios +/// @notice The live-Anvil E2E scenario runner + stakeholder DEMO (deliverable 2). +/// Reads the {DeployStack} artifact and drives the 7-scenario suite +/// against the live node, printing a one-line narrative + observable +/// evidence + PASS/FAIL per scenario. Any FAIL reverts the script → the +/// shell runner exits non-zero. +/// +/// @dev Two execution modes are used deliberately: +/// - `vm.broadcast(pk)` for state-changing steps that must PERSIST on-chain and +/// must originate from a specific account (onboarding as deployer/operator, +/// compliant trades as the investor). +/// - `vm.prank(addr)` + `try/catch` for steps that are EXPECTED TO REVERT +/// (compliance / policy / authz rejections). A reverting call is never +/// broadcast; pranking sets `msg.sender` so the router reaches the real gate +/// instead of the caller-binding check. +contract DemoScenarios is Script, DemoConstants { + // --- resolved from the artifact -------------------------------------- + address internal deployer; + address internal investor; + address internal maker; + address internal unapprovedMaker; + + uint256 internal deployerPk; + uint256 internal investorPk; + uint256 internal makerPk; + uint256 internal unapprovedMakerPk; + + IERC20 internal rwa; + IERC20 internal quote; + address internal pool; + + CornerStoreFactory internal factory; + TokenPolicyRegistry internal policyReg; + Jurisdiction internal jurisdiction; + SurveillanceFlag internal surveillance; + ExecutionRouter internal router; + UniswapV3Adapter internal ammAdapter; + RFQAdapter internal rfqAdapter; + + // per-initiator router nonce sequence for the investor. + uint256 internal nonceSeq = 1; + + // scenario bookkeeping. + uint256 internal constant N = 7; + bool[N + 1] internal passed; // 1-indexed + string[N + 1] internal titles; + + function run() external { + _load(); + + _scenario1_onboarding(); + _scenario2_compliantTrade(); + _scenario3_elementRejection(); + _scenario4_lifecycle(); + _scenario5_rfq(); + _scenario6_surveillance(); + _scenario7_bypass(); + + _summary(); + } + + // --------------------------------------------------------------------- + // Scenario 1 — Onboarding + // --------------------------------------------------------------------- + function _scenario1_onboarding() internal { + _title(1, "Onboarding: factory one-call onboards the RWA token (propose -> approve + venue)"); + + ManifestCore memory m; + m.issuanceRecipeId = 1; // Reg D 506(c) + m.issuanceRecipeVersion = 1; + m.supportedEngines = ENGINES_AMM | ENGINES_RFQ; + + VenueConfig memory ammCfg = VenueConfig({ + venueType: VenueType.AMM, + adapter: address(ammAdapter), + target: pool, + operator: address(0), + custody: CustodyModel.POOL, + active: true + }); + + vm.broadcast(deployerPk); + factory.registerRWAToken(address(rwa), m, pool, ammCfg); + + ManifestCore memory stored = policyReg.manifestOf(address(rwa)); + bool ok = stored.status == PolicyStatus.ACTIVE && stored.declaredBy == address(factory) + && stored.approvedBy == address(factory); + console2.log(" evidence: manifest status ACTIVE, declared+approved by factory"); + console2.log(" status(2=ACTIVE) :", uint256(stored.status)); + _record(1, ok); + } + + // --------------------------------------------------------------------- + // Scenario 2 — Compliant trade succeeds + // --------------------------------------------------------------------- + function _scenario2_compliantTrade() internal { + _title(2, "Compliant trade: fully-attested investor buys RWA via router -> AMM"); + + uint256 before = rwa.balanceOf(investor); + ExecutionRequest memory req = _buyRequest(AMM_TRADE); + + vm.broadcast(investorPk); + router.execute(req); + + uint256 delta = rwa.balanceOf(investor) - before; + console2.log(" evidence: Executed; investor RWA balance delta (wei):", delta); + _record(2, delta == AMM_TRADE); + } + + // --------------------------------------------------------------------- + // Scenario 3 — Element rejection, live + // --------------------------------------------------------------------- + function _scenario3_elementRejection() internal { + _title(3, "Element rejection: operator flips jurisdiction (A-02) -> same trade is rejected"); + + // flip ONE attestation: investor jurisdiction -> disallowed code. + vm.broadcast(deployerPk); + jurisdiction.setJurisdiction(investor, bytes32("ZZ")); + + ExecutionRequest memory req = _buyRequest(AMM_TRADE); + bytes32 expected = ReasonCodes.encode(1, bytes32("A-02-v1"), uint32(1)); + + (bool reverted, bytes32 reason) = _tryExecuteExpectComplianceReject(req); + bool ok = reverted && reason == expected; + console2.log(" evidence: ComplianceRejected; reason matches encode(1, A-02-v1, 1) ->", ok); + if (ok) console2.log(" rejected by A-02 Jurisdiction"); + + // restore the attestation so later scenarios see a compliant investor. + vm.broadcast(deployerPk); + jurisdiction.setJurisdiction(investor, ALLOWED_JURISDICTION); + + _record(3, ok); + } + + // --------------------------------------------------------------------- + // Scenario 4 — Lifecycle (suspend blocks, resume settles) + // --------------------------------------------------------------------- + function _scenario4_lifecycle() internal { + _title(4, "Lifecycle: suspendManifest blocks the trade; resumeManifest settles it again"); + + vm.broadcast(deployerPk); + policyReg.suspendManifest(address(rwa), bytes32("DEMO-SUSPEND")); + + ExecutionRequest memory blockedReq = _buyRequest(AMM_TRADE); + // the helper decodes `reason` ONLY for ComplianceRejected, so a nonzero + // reason proves the block came from the compliance gate specifically, + // not from an unrelated revert. + (bool reverted, bytes32 blockedReason) = _tryExecuteExpectComplianceReject(blockedReq); + bool blockedOk = reverted && blockedReason != bytes32(0); + console2.log(" evidence: while SUSPENDED, trade reverts ComplianceRejected ->", blockedOk); + + vm.broadcast(deployerPk); + policyReg.resumeManifest(address(rwa)); + + uint256 before = rwa.balanceOf(investor); + ExecutionRequest memory okReq = _buyRequest(AMM_TRADE); + vm.broadcast(investorPk); + router.execute(okReq); + uint256 delta = rwa.balanceOf(investor) - before; + console2.log(" evidence: after RESUME, trade settles; RWA delta (wei):", delta); + + _record(4, blockedOk && delta == AMM_TRADE); + } + + // --------------------------------------------------------------------- + // Scenario 5 — RFQ venue + // --------------------------------------------------------------------- + function _scenario5_rfq() internal { + _title(5, "RFQ venue: maker signs an EIP-712 quote off-chain; taker settles through the router"); + + // (a) approved maker fill. + uint256 invBefore = rwa.balanceOf(investor); + uint256 makerBefore = rwa.balanceOf(maker); + (RFQQuote memory q, ExecutionRequest memory req) = _rfqRequest(maker, makerPk, 1); + + vm.broadcast(investorPk); + router.execute(req); + + uint256 invDelta = rwa.balanceOf(investor) - invBefore; + uint256 makerOut = makerBefore - rwa.balanceOf(maker); + bool fillOk = invDelta == RFQ_RWA_OUT && makerOut == RFQ_RWA_OUT && quote.balanceOf(maker) >= RFQ_QUOTE_IN; + console2.log(" evidence: RFQFilled; taker RWA delta (wei):", invDelta); + q; // silence unused + + // (b) unapproved maker is rejected before any settlement. + (, ExecutionRequest memory badReq) = _rfqRequest(unapprovedMaker, unapprovedMakerPk, 1); + (bool rejected, bytes4 sel) = _tryExecuteExpectSelector(badReq); + bool unapprovedOk = rejected && sel == Errors.RFQMakerNotApproved.selector; + console2.log(" evidence: unapproved maker quote rejected (RFQMakerNotApproved) ->", unapprovedOk); + + _record(5, fillOk && unapprovedOk); + } + + // --------------------------------------------------------------------- + // Scenario 6 — Surveillance (stateful layer) + // --------------------------------------------------------------------- + function _scenario6_surveillance() internal { + _title(6, "Surveillance: repeated trades past the threshold emit a SurveillanceFlag"); + + // Re-onboard the RWA under a surveillance-enabled recipe (id 7 = RegD + + // F-02). retire (operator) -> factory re-register+approve (owner). + vm.broadcast(deployerPk); + policyReg.retireManifest(address(rwa), bytes32("ADD-SURVEILLANCE")); + + ManifestCore memory m; + m.issuanceRecipeId = SURVEIL_RECIPE_ID; + m.issuanceRecipeVersion = 1; + m.supportedEngines = ENGINES_AMM | ENGINES_RFQ; + VenueConfig memory ammCfg = VenueConfig({ + venueType: VenueType.AMM, + adapter: address(ammAdapter), + target: pool, + operator: address(0), + custody: CustodyModel.POOL, + active: true + }); + vm.broadcast(deployerPk); + factory.registerRWAToken(address(rwa), m, pool, ammCfg); + + uint256 threshold = 2; + vm.broadcast(deployerPk); + surveillance.setThreshold(threshold); + + uint256 startCount = surveillance.transferCount(); + + vm.recordLogs(); + for (uint256 i = 0; i < 3; i++) { + ExecutionRequest memory req = _buyRequest(AMM_TRADE); + vm.broadcast(investorPk); + router.execute(req); + } + bool flagLogged = _sawSurveillanceFlag(); + + uint256 endCount = surveillance.transferCount(); + // The flag is emitted inside onTransfer whenever transferCount > threshold, + // so crossing the threshold is a definitive on-chain witness of the flag. + bool crossed = endCount > threshold && endCount == startCount + 3; + console2.log(" evidence: transferCount crossed threshold; count:", endCount); + console2.log(" SurveillanceFlag log observed ->", flagLogged); + _record(6, crossed && flagLogged); + } + + // --------------------------------------------------------------------- + // Scenario 7 — Bypass attempt + // --------------------------------------------------------------------- + function _scenario7_bypass() internal { + _title(7, "Bypass attempt: direct adapter.execute (around the router) reverts NotAuthorized"); + + ExecutionRequest memory req = _buyRequest(AMM_TRADE); + ComplianceDecision memory d; // unused: onlyRouter reverts first + + bool reverted; + bytes4 sel; + try ammAdapter.execute(req, d) { + reverted = false; + } catch (bytes memory err) { + reverted = true; + sel = _selector(err); + } + bool ok = reverted && sel == Errors.NotAuthorized.selector; + console2.log(" evidence: compliance cannot be skipped by going around the router ->", ok); + _record(7, ok); + } + + // --------------------------------------------------------------------- + // Request builders + // --------------------------------------------------------------------- + function _buyRequest(uint256 amount) internal returns (ExecutionRequest memory req) { + ComplianceContext memory ctx; + ctx.initiator = investor; + ctx.buyer = investor; + ctx.seller = pool; + ctx.tokenIn = address(quote); + ctx.tokenOut = address(rwa); + ctx.amountIn = amount; + ctx.amountOut = amount; // 1:1 MockPool + ctx.venueType = VenueType.AMM; + ctx.venue = pool; + ctx.flowType = FlowType.SECONDARY_TRADE; + + req.context = ctx; + req.amountOutMin = 0; + req.deadline = uint64(block.timestamp + 1 hours); + req.nonce = nonceSeq++; + req.venueData = ""; // default zeroForOne=true: token0(QUOTE) in, token1(RWA) out + } + + function _rfqRequest(address mk, uint256 mkPk, uint256 quoteNonce) + internal + returns (RFQQuote memory q, ExecutionRequest memory req) + { + q.maker = mk; + q.taker = investor; + q.tokenIn = address(quote); + q.tokenOut = address(rwa); + q.amountIn = RFQ_QUOTE_IN; + q.amountOut = RFQ_RWA_OUT; + q.venue = RFQ_VENUE; + q.nonce = quoteNonce; + q.expiry = uint64(block.timestamp + 1 hours); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(mkPk, rfqAdapter.hashQuote(q)); + bytes memory sig = abi.encodePacked(r, s, v); + + ComplianceContext memory ctx; + ctx.initiator = investor; + ctx.buyer = investor; + ctx.seller = mk; + ctx.tokenIn = address(quote); + ctx.tokenOut = address(rwa); + ctx.amountIn = RFQ_QUOTE_IN; + ctx.amountOut = RFQ_RWA_OUT; + ctx.venueType = VenueType.RFQ; + ctx.venue = RFQ_VENUE; + ctx.flowType = FlowType.SECONDARY_TRADE; + + req.context = ctx; + req.amountOutMin = RFQ_RWA_OUT; + req.deadline = uint64(block.timestamp + 1 hours); + req.nonce = nonceSeq++; + req.venueData = abi.encode(q, sig); + } + + // --------------------------------------------------------------------- + // Revert helpers (simulation-only; pranked, never broadcast) + // --------------------------------------------------------------------- + function _tryExecuteExpectComplianceReject(ExecutionRequest memory req) + internal + returns (bool reverted, bytes32 reason) + { + vm.prank(investor); + try router.execute(req) { + reverted = false; + } catch (bytes memory err) { + reverted = true; + if (_selector(err) == Errors.ComplianceRejected.selector && err.length >= 36) { + assembly { + reason := mload(add(err, 0x24)) + } + } + } + } + + function _tryExecuteExpectSelector(ExecutionRequest memory req) internal returns (bool reverted, bytes4 sel) { + vm.prank(investor); + try router.execute(req) { + reverted = false; + } catch (bytes memory err) { + reverted = true; + sel = _selector(err); + } + } + + function _selector(bytes memory err) internal pure returns (bytes4 sel) { + if (err.length >= 4) { + assembly { + sel := mload(add(err, 0x20)) + } + } + } + + function _sawSurveillanceFlag() internal returns (bool) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 sig = keccak256("SurveillanceFlag(bytes32,address,bytes32)"); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) return true; + } + return false; + } + + // --------------------------------------------------------------------- + // Reporting + // --------------------------------------------------------------------- + function _title(uint256 idx, string memory t) internal { + titles[idx] = t; + console2.log(""); + console2.log(string.concat("[", vm.toString(idx), "] ", t)); + } + + function _record(uint256 idx, bool ok) internal { + passed[idx] = ok; + console2.log(ok ? " -> PASS" : " -> FAIL"); + } + + function _summary() internal view { + uint256 ok; + console2.log(""); + console2.log("====================================================="); + console2.log(" Corner Store - DEMO SCENARIO RESULTS"); + console2.log("====================================================="); + for (uint256 i = 1; i <= N; i++) { + console2.log(string.concat(" [", vm.toString(i), "] ", passed[i] ? "PASS " : "FAIL ", titles[i])); + if (passed[i]) ok++; + } + console2.log("-----------------------------------------------------"); + console2.log(string.concat(" ", vm.toString(ok), " / ", vm.toString(N), " scenarios passed")); + console2.log("====================================================="); + require(ok == N, "DEMO FAILED: one or more scenarios did not pass"); + } + + // --------------------------------------------------------------------- + // Artifact loading + // --------------------------------------------------------------------- + function _load() internal { + string memory json = vm.readFile(ARTIFACT_PATH); + + deployer = vm.parseJsonAddress(json, ".deployer"); + investor = vm.parseJsonAddress(json, ".investor"); + maker = vm.parseJsonAddress(json, ".maker"); + unapprovedMaker = vm.parseJsonAddress(json, ".unapprovedMaker"); + + deployerPk = vm.deriveKey(MNEMONIC, 0); + investorPk = vm.deriveKey(MNEMONIC, 1); + makerPk = vm.deriveKey(MNEMONIC, 2); + unapprovedMakerPk = vm.deriveKey(MNEMONIC, 3); + + rwa = IERC20(vm.parseJsonAddress(json, ".rwaToken")); + quote = IERC20(vm.parseJsonAddress(json, ".quote")); + pool = vm.parseJsonAddress(json, ".pool"); + + factory = CornerStoreFactory(vm.parseJsonAddress(json, ".factory")); + policyReg = TokenPolicyRegistry(vm.parseJsonAddress(json, ".policyReg")); + jurisdiction = Jurisdiction(vm.parseJsonAddress(json, ".jurisdiction")); + surveillance = SurveillanceFlag(vm.parseJsonAddress(json, ".surveillance")); + router = ExecutionRouter(vm.parseJsonAddress(json, ".router")); + ammAdapter = UniswapV3Adapter(vm.parseJsonAddress(json, ".ammAdapter")); + rfqAdapter = RFQAdapter(vm.parseJsonAddress(json, ".rfqAdapter")); + } +} diff --git a/script/DeployStack.s.sol b/script/DeployStack.s.sol new file mode 100644 index 0000000..cfdd902 --- /dev/null +++ b/script/DeployStack.s.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {TREXCore} from "../test/fixtures/TREXCore.sol"; + +import {ElementRegistry} from "../src/registry/ElementRegistry.sol"; +import {RecipeRegistry} from "../src/registry/RecipeRegistry.sol"; +import {TokenPolicyRegistry} from "../src/registry/TokenPolicyRegistry.sol"; +import {OperatorRegistry} from "../src/registry/OperatorRegistry.sol"; + +import {ComplianceEngine} from "../src/compliance/ComplianceEngine.sol"; +import {Sanctions} from "../src/compliance/elements/Sanctions.sol"; +import {AccreditedInvestor} from "../src/compliance/elements/AccreditedInvestor.sol"; +import {QualifiedPurchaser} from "../src/compliance/elements/QualifiedPurchaser.sol"; +import {SurveillanceFlag} from "../src/compliance/elements/SurveillanceFlag.sol"; +import {Jurisdiction} from "../src/compliance/elements/Jurisdiction.sol"; +import {IdentityUniqueness} from "../src/compliance/elements/IdentityUniqueness.sol"; +import {UsTaxResident} from "../src/compliance/elements/UsTaxResident.sol"; +import {AssetClassification} from "../src/compliance/elements/AssetClassification.sol"; +import {Erc3643Native} from "../src/compliance/elements/Erc3643Native.sol"; +import {FormDFiling} from "../src/compliance/elements/FormDFiling.sol"; +import {Lockup} from "../src/compliance/elements/Lockup.sol"; +import {IAcquisitionSource} from "../src/interfaces/compliance/IAcquisitionSource.sol"; +import {RegD506cRecipe} from "../src/compliance/recipes/RegD506cRecipe.sol"; +import {Fund3c7Recipe} from "../src/compliance/recipes/Fund3c7Recipe.sol"; + +import {ExecutionRouter} from "../src/execution/ExecutionRouter.sol"; +import {VenueRegistry} from "../src/execution/VenueRegistry.sol"; +import {VenueSelector} from "../src/execution/VenueSelector.sol"; +import {UniswapV3Adapter} from "../src/execution/adapters/amm/UniswapV3Adapter.sol"; +import {RFQAdapter} from "../src/execution/adapters/rfq/RFQAdapter.sol"; + +import {CornerStoreFactory} from "../src/factory/CornerStoreFactory.sol"; +import {ITokenPolicyRegistry} from "../src/interfaces/compliance/ITokenPolicyRegistry.sol"; +import {IVenueRegistry} from "../src/interfaces/execution/IVenueRegistry.sol"; + +import {MockERC20} from "../test/mocks/MockERC20.sol"; +import {MockPool} from "../test/mocks/MockPool.sol"; + +import {VenueType} from "../src/types/ComplianceTypes.sol"; +import {VenueConfig, CustodyModel} from "../src/types/VenueTypes.sol"; +import {DemoConstants} from "./DemoConstants.sol"; + +/// @title DeployStack +/// @notice Deploys the FULL Corner Store stack onto a live node (Anvil) from a +/// single forge script, then persists all addresses to a JSON artifact +/// that {DemoScenarios} reads. This is deliverable (1) of the live-Anvil +/// E2E / demo runner. +/// +/// @dev Reuses {TREXCore} (shared with the test fixture) for the REAL ERC-3643 + +/// OnchainID deployment. Everything is broadcast by the deployer (Anvil account +/// 0) except the two per-account token approvals, which must originate from the +/// investor and the RFQ maker (Anvil accounts 1 and 2). +/// +/// ONBOARDING IS INTENTIONALLY LEFT TO {DemoScenarios}: this script does NOT +/// register the RWA manifest or its AMM venue — scenario 1 demonstrates that +/// one-call factory onboarding live. To make that possible while the demo also +/// drives the manifest lifecycle directly (suspend/resume/retire), we: +/// - set the deployer as an explicit OPERATOR on the TokenPolicyRegistry, then +/// - transfer TokenPolicyRegistry + VenueRegistry OWNERSHIP to the factory. +/// The factory (owner) can therefore register/approve manifests and venues, while +/// the deployer (operator) retains suspend/resume/retire/approve rights. The RFQ +/// venue and the surveillance-enabled recipe (id 7, for scenario 6) are set up +/// here, before ownership moves. +contract DeployStack is Script, TREXCore, DemoConstants { + // --- Corner Store stack ---------------------------------------------- + ElementRegistry internal elementReg; + RecipeRegistry internal recipeReg; + TokenPolicyRegistry internal policyReg; + OperatorRegistry internal operatorReg; + ComplianceEngine internal engine; + + Jurisdiction internal jurisdiction; + SurveillanceFlag internal surveillance; + AssetClassification internal assetClass; + Erc3643Native internal erc3643; + FormDFiling internal formD; + Lockup internal lockup; + DemoAcquisitionSource internal acqSource; + + ExecutionRouter internal router; + VenueRegistry internal venueReg; + VenueSelector internal selector; + UniswapV3Adapter internal ammAdapter; + RFQAdapter internal rfqAdapter; + CornerStoreFactory internal factory; + + MockERC20 internal quote; + MockPool internal pool; + + function run() external { + uint256 deployerPk = vm.deriveKey(MNEMONIC, 0); + uint256 investorPk = vm.deriveKey(MNEMONIC, 1); + uint256 makerPk = vm.deriveKey(MNEMONIC, 2); + address deployer = vm.addr(deployerPk); + address investor = vm.addr(investorPk); + address maker = vm.addr(makerPk); + address unapprovedMaker = vm.addr(vm.deriveKey(MNEMONIC, 3)); + + vm.startBroadcast(deployerPk); + + // 1. REAL ERC-3643 (T-REX) + OnchainID, admin = deployer. + deployTREX(deployer); + + // 2. compliance registries + engine. + elementReg = new ElementRegistry(); + recipeReg = new RecipeRegistry(); + policyReg = new TokenPolicyRegistry(); + operatorReg = new OperatorRegistry(); + + // 3. the full 9-element Reg D 506(c) reference set + surveillance. + _deployAndRegisterElements(); + + // 4. recipes: RegD 506(c) (id 1) + 3(c)(7) fund (id 2) + a + // surveillance-enabled RegD variant (id 7) used by scenario 6. + recipeReg.registerRecipe(1, 2, address(new RegD506cRecipe())); + recipeReg.registerRecipe(2, 1, address(new Fund3c7Recipe())); + recipeReg.registerRecipe(SURVEIL_RECIPE_ID, 1, address(new DemoSurveillanceRecipe())); + + engine = new ComplianceEngine(policyReg, elementReg, recipeReg); + + // 5. execution stack + factory. + venueReg = new VenueRegistry(); + selector = new VenueSelector(); + ammAdapter = new UniswapV3Adapter(); + rfqAdapter = new RFQAdapter(); + router = new ExecutionRouter(engine, venueReg, selector, operatorReg); + factory = new CornerStoreFactory(ITokenPolicyRegistry(address(policyReg)), IVenueRegistry(address(venueReg))); + + // authenticate the post-trade write path (spec §6). + engine.setRouter(address(router)); + ammAdapter.setRouter(address(router)); + rfqAdapter.setRouter(address(router)); + surveillance.setEngine(address(engine)); + + // 6. tokens + AMM pool (token0=QUOTE, token1=RWA). + quote = new MockERC20("Quote USD", "qUSD"); + pool = new MockPool(IERC20(address(quote)), IERC20(address(rwaToken))); + + // 7. asset-side attestations (RWA is REG_D, ERC-3643-native, Form D filed) + // and the allowed investor jurisdiction. + assetClass.setClassification(address(rwaToken), REG_D_CLASS); + erc3643.setErc3643Native(address(rwaToken), true); + formD.setFormDFiled(address(rwaToken), true, bytes32("EDGAR-ACCESSION")); + jurisdiction.setJurisdictionAllowed(ALLOWED_JURISDICTION, true); + + // 8. classify QUOTE as out-of-scope (owner-gated; before ownership moves). + policyReg.setUnregulated(address(quote)); + + // 9. verified holders + liquidity: investor (buyer/taker), maker (dealer), + // pool (custody-as-holder). Investor gets full engine attestations. + verifyInvestor(investor); + _attestInvestor(investor); + verifyInvestor(maker); + registerVenueIdentity(address(pool)); + + quote.mint(investor, INVESTOR_QUOTE); + mint(maker, MAKER_RWA); + mint(address(pool), POOL_RWA); + + ammAdapter.setPool(address(pool), true); + rfqAdapter.setMakerApproved(maker, true); + + // 10. register the RFQ venue now (owner-gated; before ownership moves). + venueReg.registerVenue( + RFQ_VENUE, + VenueConfig({ + venueType: VenueType.RFQ, + adapter: address(rfqAdapter), + target: address(0), + operator: address(0), + custody: CustodyModel.NONE, + active: true + }) + ); + + // 11. hand the registries to the factory for one-call onboarding, but keep + // the deployer as an operator so it can still drive the lifecycle. + policyReg.setOperator(deployer, true); + policyReg.transferOwnership(address(factory)); + venueReg.transferOwnership(address(factory)); + + vm.stopBroadcast(); + + // 12. per-account token approvals (must originate from their owners). + vm.startBroadcast(investorPk); + quote.approve(address(ammAdapter), type(uint256).max); + quote.approve(address(rfqAdapter), type(uint256).max); + vm.stopBroadcast(); + + vm.startBroadcast(makerPk); + rwaToken.approve(address(rfqAdapter), type(uint256).max); + vm.stopBroadcast(); + + _writeArtifact(deployer, investor, maker, unapprovedMaker); + _printSummary(deployer, investor, maker); + } + + function _deployAndRegisterElements() internal { + elementReg.registerElement(bytes32("A-01-v1"), address(new Sanctions())); + jurisdiction = new Jurisdiction(); + elementReg.registerElement(bytes32("A-02-v1"), address(jurisdiction)); + elementReg.registerElement(bytes32("A-03-v1"), address(new AccreditedInvestor())); + elementReg.registerElement(bytes32("A-04-v1"), address(new IdentityUniqueness())); + elementReg.registerElement(bytes32("A-05-v1"), address(new UsTaxResident())); + assetClass = new AssetClassification(REG_D_CLASS); + elementReg.registerElement(bytes32("B-01-v1"), address(assetClass)); + erc3643 = new Erc3643Native(); + elementReg.registerElement(bytes32("B-02-v1"), address(erc3643)); + acqSource = new DemoAcquisitionSource(); + lockup = new Lockup(address(acqSource), LOCKUP_SECONDS); + elementReg.registerElement(bytes32("C-01-v1"), address(lockup)); + formD = new FormDFiling(); + elementReg.registerElement(bytes32("E-01-v1"), address(formD)); + surveillance = new SurveillanceFlag(); + elementReg.registerElement(bytes32("A-13-v1"), address(new QualifiedPurchaser())); + elementReg.registerElement(bytes32("F-02-v1"), address(surveillance)); + } + + /// @dev Full investor-side engine attestations for the 9-element recipe. + /// Sanctions (A-01) and US-tax (A-05) pass by default (not blocked / + /// not flagged). Anvil's genesis timestamp is real wall-clock time, far + /// past the Rule 144 lockup window seeded at t=1, so C-01 passes on-chain. + function _attestInvestor(address who) internal { + jurisdiction.setJurisdiction(who, ALLOWED_JURISDICTION); // A-02 + IdentityUniqueness(elementReg.elementOf(bytes32("A-04-v1"))).bindIdentity(who, keccak256(abi.encode("ID", who))); // A-04 + AccreditedInvestor(elementReg.elementOf(bytes32("A-03-v1"))).setAccredited(who, true); // A-03 + acqSource.setAcquiredAt(who, address(rwaToken), uint64(1)); // C-01 seed + } + + function _writeArtifact(address deployer, address investor, address maker, address unapprovedMaker) internal { + vm.createDir("deployments", true); // idempotent (recursive) + string memory k = "corner-store-e2e"; + vm.serializeAddress(k, "deployer", deployer); + vm.serializeAddress(k, "investor", investor); + vm.serializeAddress(k, "maker", maker); + vm.serializeAddress(k, "unapprovedMaker", unapprovedMaker); + vm.serializeAddress(k, "rwaToken", address(rwaToken)); + vm.serializeAddress(k, "quote", address(quote)); + vm.serializeAddress(k, "pool", address(pool)); + vm.serializeAddress(k, "rfqVenue", RFQ_VENUE); + vm.serializeAddress(k, "elementReg", address(elementReg)); + vm.serializeAddress(k, "recipeReg", address(recipeReg)); + vm.serializeAddress(k, "policyReg", address(policyReg)); + vm.serializeAddress(k, "operatorReg", address(operatorReg)); + vm.serializeAddress(k, "engine", address(engine)); + vm.serializeAddress(k, "venueReg", address(venueReg)); + vm.serializeAddress(k, "selector", address(selector)); + vm.serializeAddress(k, "ammAdapter", address(ammAdapter)); + vm.serializeAddress(k, "rfqAdapter", address(rfqAdapter)); + vm.serializeAddress(k, "router", address(router)); + vm.serializeAddress(k, "factory", address(factory)); + vm.serializeAddress(k, "jurisdiction", address(jurisdiction)); + string memory json = vm.serializeAddress(k, "surveillance", address(surveillance)); + vm.writeJson(json, ARTIFACT_PATH); + } + + function _printSummary(address deployer, address investor, address maker) internal view { + console2.log("====================================================="); + console2.log(" Corner Store - live stack deployed"); + console2.log("====================================================="); + console2.log("deployer (owner/operator) :", deployer); + console2.log("investor (buyer/taker) :", investor); + console2.log("rfq maker (dealer) :", maker); + console2.log("-----------------------------------------------------"); + console2.log("RWA token (ERC-3643) :", address(rwaToken)); + console2.log("QUOTE (unregulated cash) :", address(quote)); + console2.log("AMM pool (MockPool) :", address(pool)); + console2.log("ComplianceEngine :", address(engine)); + console2.log("ExecutionRouter :", address(router)); + console2.log("CornerStoreFactory :", address(factory)); + console2.log("-----------------------------------------------------"); + console2.log("artifact written to :", ARTIFACT_PATH); + console2.log("====================================================="); + } +} + +/// @dev Live-node acquisition-time source for the Lockup (C-01) element's CR-3 +/// seam. Mirrors the test fixture's `MockAcquisitionSource`. +contract DemoAcquisitionSource is IAcquisitionSource { + mapping(bytes32 => uint64) internal _acquiredAt; + + function setAcquiredAt(address holder, address asset, uint64 ts) external { + _acquiredAt[keccak256(abi.encode(holder, asset))] = ts; + } + + function acquiredAt(address holder, address asset) external view override returns (uint64) { + return _acquiredAt[keccak256(abi.encode(holder, asset))]; + } +} + +/// @dev Always-applicable recipe requiring the full Reg D 506(c) 9-element set +/// PLUS conduct surveillance (F-02, STATEFUL). Registered as recipe id 7 and +/// used by scenario 6 to drive the post-trade `onTransfer` surveillance flag +/// without changing the compliance posture of the base policy. +contract DemoSurveillanceRecipe { + function recipeId() external pure returns (uint16) { + return 7; + } + + function version() external pure returns (uint16) { + return 1; + } + + function isApplicable(bytes calldata) external pure returns (bool) { + return true; + } + + function requiredElements() external pure returns (bytes32[] memory e) { + e = new bytes32[](10); + e[0] = "A-01-v1"; + e[1] = "A-02-v1"; + e[2] = "A-03-v1"; + e[3] = "A-04-v1"; + e[4] = "A-05-v1"; + e[5] = "B-01-v1"; + e[6] = "B-02-v1"; + e[7] = "C-01-v1"; + e[8] = "E-01-v1"; + e[9] = "F-02-v1"; + } +} diff --git a/scripts/e2e-anvil.sh b/scripts/e2e-anvil.sh new file mode 100755 index 0000000..05c6aef --- /dev/null +++ b/scripts/e2e-anvil.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Live-Anvil E2E runner + stakeholder demo driver. +# +# Starts a fresh Anvil node, deploys the FULL Corner Store stack (DeployStack), +# runs the 7-scenario demo suite (DemoScenarios), prints the PASS/FAIL summary, +# and tears the node down on exit. Runs fully offline. +# +# Usage: +# scripts/e2e-anvil.sh [--port N] [--keep] +# +# --port N Anvil port (default 8545). +# --keep Leave Anvil running after the suite (attach a UI / continue the +# demo interactively). Otherwise Anvil is killed on exit. +# +# Exit code is non-zero if any scenario fails (DemoScenarios reverts). +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +PORT=8545 +KEEP=0 +while [ $# -gt 0 ]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --port=*) PORT="${1#*=}"; shift ;; + --keep) KEEP=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +RPC="http://127.0.0.1:${PORT}" +ANVIL_LOG=$(mktemp -t corner-store-anvil.XXXXXX) +ANVIL_PID="" + +cleanup() { + if [ "$KEEP" -eq 1 ]; then + if [ -n "$ANVIL_PID" ]; then + echo "" + echo "==> --keep set: Anvil left running (pid ${ANVIL_PID}) on ${RPC}" + echo " stop it with: kill ${ANVIL_PID}" + fi + return + fi + if [ -n "$ANVIL_PID" ] && kill -0 "$ANVIL_PID" 2>/dev/null; then + kill "$ANVIL_PID" 2>/dev/null || true + wait "$ANVIL_PID" 2>/dev/null || true + fi + rm -f "$ANVIL_LOG" +} +trap cleanup EXIT INT TERM + +mkdir -p deployments + +echo "==> Starting Anvil on ${RPC} (offline, deterministic mnemonic)" +anvil --port "$PORT" --silent >"$ANVIL_LOG" 2>&1 & +ANVIL_PID=$! + +echo "==> Waiting for Anvil to accept connections" +READY=0 +for _ in $(seq 1 50); do + if cast block-number --rpc-url "$RPC" >/dev/null 2>&1; then + READY=1 + break + fi + sleep 0.2 +done +if [ "$READY" -ne 1 ]; then + echo "ERROR: Anvil did not become ready" >&2 + cat "$ANVIL_LOG" >&2 || true + exit 1 +fi + +echo "==> Deploying the full stack (script/DeployStack.s.sol)" +forge script script/DeployStack.s.sol:DeployStack \ + --rpc-url "$RPC" --broadcast --offline + +echo "" +echo "==> Running the demo scenario suite (script/DemoScenarios.s.sol)" +forge script script/DemoScenarios.s.sol:DemoScenarios \ + --rpc-url "$RPC" --broadcast --offline + +echo "" +echo "==> E2E demo complete: all scenarios passed." diff --git a/test/fixtures/TREXCore.sol b/test/fixtures/TREXCore.sol new file mode 100644 index 0000000..2fa81b6 --- /dev/null +++ b/test/fixtures/TREXCore.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {CommonBase} from "forge-std/Base.sol"; + +import {Token} from "@erc3643/token/Token.sol"; +import {IToken} from "@erc3643/token/IToken.sol"; +import {IdentityRegistry} from "@erc3643/registry/implementation/IdentityRegistry.sol"; +import {IdentityRegistryStorage} from "@erc3643/registry/implementation/IdentityRegistryStorage.sol"; +import {ClaimTopicsRegistry} from "@erc3643/registry/implementation/ClaimTopicsRegistry.sol"; +import {TrustedIssuersRegistry} from "@erc3643/registry/implementation/TrustedIssuersRegistry.sol"; +import {ModularCompliance} from "@erc3643/compliance/modular/ModularCompliance.sol"; + +import {Identity} from "@onchain-id/solidity/contracts/Identity.sol"; +import {ClaimIssuer} from "@onchain-id/solidity/contracts/ClaimIssuer.sol"; +import {IIdentity} from "@onchain-id/solidity/contracts/interface/IIdentity.sol"; +import {IClaimIssuer} from "@onchain-id/solidity/contracts/interface/IClaimIssuer.sol"; + +/// @title TREXCore +/// @notice Deployable, REAL ERC-3643 (T-REX) + OnchainID deployment core, shared +/// by the test fixture ({TREXSuite}) and the live deploy script +/// ({DeployStack}). +/// +/// Stands up a fully-wired permissioned-token stack so that +/// `identityRegistry.isVerified(investor)` is genuinely enforced and +/// `token.transfer` honours on-chain compliance — no mocking of the token, +/// registry, or claim-verification path. +/// +/// @dev This is `abstract contract is CommonBase` (forge-std), which is the +/// shared ancestor of both `Test` and `Script`, so the SAME deployment logic runs +/// under `forge test` (in-memory EVM) and under `forge script --broadcast` +/// (against a live node) without duplication. Building a valid OnchainID claim +/// requires `vm.sign` (the claim signature is an `eth_sign`-prefixed ECDSA +/// signature by the trusted ClaimIssuer's key), which `CommonBase` exposes. +/// +/// ADMIN MODEL (why there is no `vm.prank`): every deployed OnchainID is created +/// with `trexAdmin` as its management key, and the KYC claim is added by +/// `trexAdmin`. In a test `trexAdmin == address(this)` (the fixture is the caller +/// of `addClaim`); in a broadcast script `trexAdmin == the deployer EOA` (the +/// broadcaster is the caller). In BOTH cases `msg.sender == trexAdmin == the +/// identity's management key`, so `addClaim` authorizes with no prank/broadcast +/// juggling. The identity's management key is irrelevant to `isVerified` (which +/// only checks a registered identity carrying a valid trusted-issuer claim), so +/// this does not weaken the real ERC-3643 verification path. +/// +/// Verification model (matches T-REX `IdentityRegistry.isVerified` + +/// OnchainID `ClaimIssuer.isClaimValid`): +/// - ONE required claim topic (`CLAIM_TOPIC_KYC`). +/// - ONE trusted ClaimIssuer, whose management key (also a claim/purpose-1 +/// key, which `keyHasPurpose` treats as satisfying any purpose) signs the +/// claim. +/// - The claim signature signs: +/// keccak256("\x19Ethereum Signed Message:\n32", +/// keccak256(abi.encode(investorIdentity, topic, data))) +/// so that `ClaimIssuer.isClaimValid` recovers the issuer key and finds it +/// has purpose 3 (CLAIM) on the issuer identity. +abstract contract TREXCore is CommonBase { + // --- claim topic & issuer key ---------------------------------------- + uint256 internal constant CLAIM_TOPIC_KYC = uint256(keccak256("CORNER_STORE.KYC")); + uint256 internal constant CLAIM_SCHEME_ECDSA = 1; + uint16 internal constant DEFAULT_COUNTRY = 840; // ISO-3166 US + + // deterministic issuer signing key (foundry test key) + uint256 internal issuerKey = uint256(keccak256("CORNER_STORE.TRUSTED_ISSUER_KEY")); + address internal issuerAddr; + + // Management key of every deployed OnchainID + agent of the registry/token. + // Set by {deployTREX}; see ADMIN MODEL above. + address internal trexAdmin; + + // --- deployed T-REX stack -------------------------------------------- + ClaimTopicsRegistry internal claimTopics; + TrustedIssuersRegistry internal trustedIssuers; + IdentityRegistryStorage internal identityStorage; + IdentityRegistry internal idRegistry; + ModularCompliance internal compliance; + Token internal rwaToken; + ClaimIssuer internal claimIssuer; + + /// @notice Stand up and wire the full ERC-3643 stack with `address(this)` as + /// the admin (the in-memory test path). Call from `setUp()`. + function deployTREX() internal { + deployTREX(address(this)); + } + + /// @notice Stand up and wire the full ERC-3643 stack with an explicit admin. + /// Under `forge script --broadcast`, pass the broadcasting deployer so + /// the registry/token agents and identity management keys are that EOA. + function deployTREX(address admin) internal { + trexAdmin = admin; + issuerAddr = vm.addr(issuerKey); + + // 1. registries + claimTopics = new ClaimTopicsRegistry(); + claimTopics.init(); + claimTopics.addClaimTopic(CLAIM_TOPIC_KYC); + + trustedIssuers = new TrustedIssuersRegistry(); + trustedIssuers.init(); + + // 2. trusted ClaimIssuer (its management key signs investor claims). + // management key (purpose 1) satisfies the purpose-3 (CLAIM) check. + claimIssuer = new ClaimIssuer(issuerAddr); + uint256[] memory topics = new uint256[](1); + topics[0] = CLAIM_TOPIC_KYC; + trustedIssuers.addTrustedIssuer(IClaimIssuer(address(claimIssuer)), topics); + + // 3. identity storage + registry + identityStorage = new IdentityRegistryStorage(); + identityStorage.init(); + + idRegistry = new IdentityRegistry(); + idRegistry.init(address(trustedIssuers), address(claimTopics), address(identityStorage)); + identityStorage.bindIdentityRegistry(address(idRegistry)); + idRegistry.addAgent(admin); // admin may registerIdentity + + // 4. compliance (no modules => canTransfer always true) + compliance = new ModularCompliance(); + compliance.init(); + + // 5. token + rwaToken = new Token(); + rwaToken.init(address(idRegistry), address(compliance), "Corner Store RWA", "csRWA", 18, address(0)); + rwaToken.addAgent(admin); // admin may mint + rwaToken.unpause(); // token deploys paused + } + + // --- getters ---------------------------------------------------------- + function token() public view returns (IToken) { + return IToken(address(rwaToken)); + } + + function identityRegistry() public view returns (IdentityRegistry) { + return idRegistry; + } + + // --- verification ----------------------------------------------------- + + /// @notice Deploy an OnchainID for `investor`, attach a valid KYC claim from + /// the trusted issuer, and register it so `isVerified` becomes true. + function verifyInvestor(address investor) internal returns (Identity identity) { + identity = _deployIdentityWithKycClaim(investor); + idRegistry.registerIdentity(investor, IIdentity(address(identity)), DEFAULT_COUNTRY); + } + + /// @notice Register a venue/custodian (e.g. a pool) as a verified holder so + /// the RWA token can move to/from it (spec §8 custody-as-holder). + function registerVenueIdentity(address venue) internal returns (Identity identity) { + identity = _deployIdentityWithKycClaim(venue); + idRegistry.registerIdentity(venue, IIdentity(address(identity)), DEFAULT_COUNTRY); + } + + /// @notice Mint RWA tokens to a verified holder (admin is token agent). + function mint(address to, uint256 amount) internal { + rwaToken.mint(to, amount); + } + + // --- internals -------------------------------------------------------- + + /// @dev Deploys a usable OnchainID (management key = `trexAdmin`) and adds a + /// KYC claim signed by the trusted issuer. The claim is added by the + /// current caller, which IS `trexAdmin` (the management key) in both the + /// test and broadcast-script paths — see ADMIN MODEL on the contract. + function _deployIdentityWithKycClaim(address subject) private returns (Identity identity) { + identity = new Identity(trexAdmin, false); + + bytes memory claimData = abi.encodePacked("KYC:", subject); + + // signature payload exactly as ClaimIssuer.isClaimValid reconstructs it + bytes32 dataHash = keccak256(abi.encode(address(identity), CLAIM_TOPIC_KYC, claimData)); + bytes32 prefixed = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(issuerKey, prefixed); + bytes memory sig = abi.encodePacked(r, s, v); + + identity.addClaim(CLAIM_TOPIC_KYC, CLAIM_SCHEME_ECDSA, address(claimIssuer), sig, claimData, ""); + } +} diff --git a/test/fixtures/TREXSuite.sol b/test/fixtures/TREXSuite.sol index e8a624d..eb204ee 100644 --- a/test/fixtures/TREXSuite.sol +++ b/test/fixtures/TREXSuite.sol @@ -3,150 +3,17 @@ pragma solidity 0.8.17; import {Test} from "forge-std/Test.sol"; -import {Token} from "@erc3643/token/Token.sol"; -import {IToken} from "@erc3643/token/IToken.sol"; -import {IdentityRegistry} from "@erc3643/registry/implementation/IdentityRegistry.sol"; -import {IdentityRegistryStorage} from "@erc3643/registry/implementation/IdentityRegistryStorage.sol"; -import {ClaimTopicsRegistry} from "@erc3643/registry/implementation/ClaimTopicsRegistry.sol"; -import {TrustedIssuersRegistry} from "@erc3643/registry/implementation/TrustedIssuersRegistry.sol"; -import {ModularCompliance} from "@erc3643/compliance/modular/ModularCompliance.sol"; - -import {Identity} from "@onchain-id/solidity/contracts/Identity.sol"; -import {ClaimIssuer} from "@onchain-id/solidity/contracts/ClaimIssuer.sol"; -import {IIdentity} from "@onchain-id/solidity/contracts/interface/IIdentity.sol"; -import {IClaimIssuer} from "@onchain-id/solidity/contracts/interface/IClaimIssuer.sol"; +import {TREXCore} from "./TREXCore.sol"; /// @title TREXSuite -/// @notice Deployable, REAL ERC-3643 (T-REX) + OnchainID test harness. -/// -/// Stands up a fully-wired permissioned-token stack so that -/// `identityRegistry.isVerified(investor)` is genuinely enforced and -/// `token.transfer` honours on-chain compliance — no mocking of the token, -/// registry, or claim-verification path. -/// -/// Design: this is an `abstract contract is Test`. A test contract inherits it -/// (`contract Foo is TREXSuite`) and calls `deployTREX()` from `setUp()`. We -/// inherit forge-std `Test` because forging a valid OnchainID claim requires -/// `vm.sign` (the claim signature is an `eth_sign`-prefixed ECDSA signature by -/// the trusted ClaimIssuer's key) and `vm.prank` (claims are added by the -/// investor's own management key). +/// @notice Test-fixture facade over the shared {TREXCore} ERC-3643 (T-REX) + +/// OnchainID deployment core. /// -/// Verification model (matches T-REX `IdentityRegistry.isVerified` + -/// OnchainID `ClaimIssuer.isClaimValid`): -/// - ONE required claim topic (`CLAIM_TOPIC_KYC`). -/// - ONE trusted ClaimIssuer, whose management key (also a claim/purpose-1 -/// key, which `keyHasPurpose` treats as satisfying any purpose) signs the -/// claim. -/// - The claim signature signs: -/// keccak256("\x19Ethereum Signed Message:\n32", -/// keccak256(abi.encode(investorIdentity, topic, data))) -/// so that `ClaimIssuer.isClaimValid` recovers the issuer key and finds it -/// has purpose 3 (CLAIM) on the issuer identity. -abstract contract TREXSuite is Test { - // --- claim topic & issuer key ---------------------------------------- - uint256 internal constant CLAIM_TOPIC_KYC = uint256(keccak256("CORNER_STORE.KYC")); - uint256 internal constant CLAIM_SCHEME_ECDSA = 1; - uint16 internal constant DEFAULT_COUNTRY = 840; // ISO-3166 US - - // deterministic issuer signing key (foundry test key) - uint256 internal issuerKey = uint256(keccak256("CORNER_STORE.TRUSTED_ISSUER_KEY")); - address internal issuerAddr; - - // --- deployed T-REX stack -------------------------------------------- - ClaimTopicsRegistry internal claimTopics; - TrustedIssuersRegistry internal trustedIssuers; - IdentityRegistryStorage internal identityStorage; - IdentityRegistry internal idRegistry; - ModularCompliance internal compliance; - Token internal rwaToken; - ClaimIssuer internal claimIssuer; - - /// @notice Stand up and wire the full ERC-3643 stack. Call from `setUp()`. - function deployTREX() internal { - issuerAddr = vm.addr(issuerKey); - - // 1. registries - claimTopics = new ClaimTopicsRegistry(); - claimTopics.init(); - claimTopics.addClaimTopic(CLAIM_TOPIC_KYC); - - trustedIssuers = new TrustedIssuersRegistry(); - trustedIssuers.init(); - - // 2. trusted ClaimIssuer (its management key signs investor claims). - // management key (purpose 1) satisfies the purpose-3 (CLAIM) check. - claimIssuer = new ClaimIssuer(issuerAddr); - uint256[] memory topics = new uint256[](1); - topics[0] = CLAIM_TOPIC_KYC; - trustedIssuers.addTrustedIssuer(IClaimIssuer(address(claimIssuer)), topics); - - // 3. identity storage + registry - identityStorage = new IdentityRegistryStorage(); - identityStorage.init(); - - idRegistry = new IdentityRegistry(); - idRegistry.init(address(trustedIssuers), address(claimTopics), address(identityStorage)); - identityStorage.bindIdentityRegistry(address(idRegistry)); - idRegistry.addAgent(address(this)); // fixture may registerIdentity - - // 4. compliance (no modules => canTransfer always true) - compliance = new ModularCompliance(); - compliance.init(); - - // 5. token - rwaToken = new Token(); - rwaToken.init(address(idRegistry), address(compliance), "Corner Store RWA", "csRWA", 18, address(0)); - rwaToken.addAgent(address(this)); // fixture may mint - rwaToken.unpause(); // token deploys paused - } - - // --- getters ---------------------------------------------------------- - function token() public view returns (IToken) { - return IToken(address(rwaToken)); - } - - function identityRegistry() public view returns (IdentityRegistry) { - return idRegistry; - } - - // --- verification ----------------------------------------------------- - - /// @notice Deploy an OnchainID for `investor`, attach a valid KYC claim from - /// the trusted issuer, and register it so `isVerified` becomes true. - function verifyInvestor(address investor) internal returns (Identity identity) { - identity = _deployIdentityWithKycClaim(investor); - idRegistry.registerIdentity(investor, IIdentity(address(identity)), DEFAULT_COUNTRY); - } - - /// @notice Register a venue/custodian (e.g. a pool) as a verified holder so - /// the RWA token can move to/from it (spec §8 custody-as-holder). - function registerVenueIdentity(address venue) internal returns (Identity identity) { - identity = _deployIdentityWithKycClaim(venue); - idRegistry.registerIdentity(venue, IIdentity(address(identity)), DEFAULT_COUNTRY); - } - - /// @notice Mint RWA tokens to a verified holder (fixture is token agent). - function mint(address to, uint256 amount) internal { - rwaToken.mint(to, amount); - } - - // --- internals -------------------------------------------------------- - - /// @dev Deploys a usable OnchainID (management key = `subject`) and adds a - /// KYC claim signed by the trusted issuer. The claim is added by the - /// subject's own management key (via prank) to satisfy `onlyClaimKey`. - function _deployIdentityWithKycClaim(address subject) private returns (Identity identity) { - identity = new Identity(subject, false); - - bytes memory claimData = abi.encodePacked("KYC:", subject); - - // signature payload exactly as ClaimIssuer.isClaimValid reconstructs it - bytes32 dataHash = keccak256(abi.encode(address(identity), CLAIM_TOPIC_KYC, claimData)); - bytes32 prefixed = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(issuerKey, prefixed); - bytes memory sig = abi.encodePacked(r, s, v); - - vm.prank(subject); // subject is the management key on its own identity - identity.addClaim(CLAIM_TOPIC_KYC, CLAIM_SCHEME_ECDSA, address(claimIssuer), sig, claimData, ""); - } -} +/// @dev The deployment logic now lives in {TREXCore} (an +/// `abstract is CommonBase`) so it can be reused verbatim by the live deploy +/// script ({DeployStack}). This facade only re-adds `forge-std/Test` (assertions, +/// `expectEmit`, `prank`, …) for the test suite; a test contract inherits it +/// (`contract Foo is TREXSuite`) and calls `deployTREX()` from `setUp()`, exactly +/// as before. `Test` and `TREXCore` share the `CommonBase` ancestor (single `vm`), +/// so the diamond resolves cleanly. +abstract contract TREXSuite is Test, TREXCore {}