diff --git a/.gitignore b/.gitignore index e9275c9..a637fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ services/toolkit/dist/ services/toolkit/node_modules/ services/operator-api/dist/ services/operator-api/node_modules/ +services/compliance-data/dist/ +services/compliance-data/node_modules/ deployments/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8764ddd..b7d3f39 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,7 @@ Execution Integration Kit로 구성한다. Corner Store reference DEX는 이 공 | `services/toolkit/` | versioned Toolkit config schema와 공통 validation primitives | | `services/operator-api/` | private-key 없는 read-only operator snapshot/event API | | `services/operator-dashboard/` | Operator API를 소비하는 read-only snapshot/proposal review 화면 | +| `services/compliance-data/` | provider-neutral TA lot, person-group state와 reject/surveillance audit SDK | | `tools/deploy-v3/` | 독립적으로 유지하는 vendored Uniswap v3 배포 도구 | | `lib/` | Foundry 의존성 | | `scripts/` | 저장소 setup, 검증과 정리 명령 | @@ -34,7 +35,9 @@ Execution Integration Kit로 구성한다. Corner Store reference DEX는 이 공 - ERC-3643과 ONCHAINID는 외부 token/identity trust boundary다. - Element, Recipe, Manifest, Operator의 이름 기반 4-Layer compliance model을 사용한다. -- applicable Recipe는 cumulative AND로 평가한다. +- Manifest는 bounded `RecipeBinding[]`로 Recipe/version/mode를 고정한다. +- `REQUIRED_BLOCKING`은 AND, 같은 `pathGroupId`의 `PATH_OPTION`은 OR, + 서로 다른 path group은 AND로 평가하며 `FLAG_ONLY` 실패는 기록만 한다. - `tokenIn`과 `tokenOut` 양쪽의 classification과 Manifest를 평가한다. - Asset Compliance Manifest는 자산별 Recipe, engine, version과 발행 측 coverage를 binding한다. diff --git a/DECISIONS.md b/DECISIONS.md index 8e1d5e5..e021847 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -169,7 +169,8 @@ Recipe를 선택하는 인상을 주었다. 법률 연구는 한 거래에 발 - Manifest: 자산별 Recipe/engine/version/coverage binding - Operator: 판단·승인·감시 -거래마다 applicable Recipe를 식별하고 Element 합집합을 cumulative AND로 평가한다. +거래마다 applicable Recipe를 식별한다. Required Recipe의 Element는 cumulative +AND로 평가하고, ADR-007 이후 명시적 path option과 flag-only binding을 분리한다. 기존 ExecutionRouter, ComplianceEngine과 Adapter 분리는 유지한다. ### Alternatives Considered @@ -486,17 +487,19 @@ custom error `Errors.InvalidManifestTransition`으로 revert한다. | UNKNOWN 또는 RETIRED | `registerManifest` | PROPOSED | onlyOwner | | PROPOSED | `approveManifest` | ACTIVE | onlyOperator (issuance recipe 필수) | | ACTIVE | `suspendManifest(reasonCode)` | SUSPENDED | onlyOperator | -| SUSPENDED | `resumeManifest` | ACTIVE | onlyOperator | +| SUSPENDED | `scheduleManifestResume` 후 `resumeManifest` | ACTIVE | owner schedule + timelock + operator execute | | ACTIVE 또는 SUSPENDED | `retireManifest(reasonCode)` | RETIRED (terminal) | onlyOperator | | UNKNOWN | `setUnregulated` | UNREGULATED | onlyOwner | | UNREGULATED | `clearUnregulated` | UNKNOWN | onlyOwner | `registerManifest`는 caller가 넣은 `m.status`를 무시하고 항상 PROPOSED로 착지하며 `declaredBy = msg.sender`를 기록한다. `approveManifest`는 `approvedBy = msg.sender`를 -기록하고 `issuanceRecipeId == 0`이면 registry-level completeness floor로 revert한다. +기록한다. MANIFEST-002 이후 completeness floor는 deprecated issuance field가 아니라 +비어 있지 않고 최소 하나의 blocking/path gate가 있는 validated `RecipeBinding[]`다. RETIRED는 terminal이며 재발행은 RETIRED→register→approve 경로로만 가능하다. gating model: owner가 자산을 classify/declare(register/setUnregulated/clearUnregulated)하고, -operator가 기존 manifest의 lifecycle(approve/suspend/resume/retire)을 구동한다. +operator가 approve/suspend/retire와 예약된 resume 실행을 구동한다. resume 예약과 +semantic update 예약은 D011에 따라 owner governance가 담당한다. **2. Enum-append storage rationale.** `PolicyStatus`에 PROPOSED(4)와 RETIRED(5)를 SUSPENDED(3) 뒤에 APPEND한다. 기존 numeric value(UNKNOWN=0, UNREGULATED=1, @@ -565,3 +568,92 @@ token의 `declaredBy`/`approvedBy`는 factory 주소이며, attribution은 facto `test/unit/registry/TokenPolicyRegistry.t.sol` - `test/integration/EmergencyPause.t.sol`, `test/integration/IntegrationBase.sol`, `test/integration/Surveillance.t.sol` + +## D011 — 위험 중단은 즉시, 재개와 Manifest 의미 변경은 timelock으로 분리한다 + +Date: 2026-07-22 + +### Context + +ADR-007은 모든 Router가 공유하는 central pause state, 즉시 containment, 지연된 +compliance relaxation, Manifest semantic version/history 보존을 요구한다. 기존 +reference stack은 venue suspension과 Manifest status만 있어 global/asset pause가 +없었고, 재개와 core fact 변경을 즉시 수행할 수 있었다. 배포 후 +`TokenPolicyRegistry.owner()`가 Factory가 되므로 owner-only governance 호출을 EOA가 +직접 실행할 수도 없었다. + +### Decision + +1. `OperatorRegistry`를 global/asset/venue pause의 source of truth로 사용하고 Router는 + nonce 소비와 compliance evaluation 전에 세 범위를 모두 fail-closed로 검사한다. +2. operator는 pause와 Manifest suspend처럼 위험을 줄이는 동작을 즉시 수행한다. + unpause는 owner가 예약하고 최소 1일 뒤 owner가 실행한다. +3. Manifest resume와 ACTIVE/SUSPENDED semantic update도 owner 예약과 최소 1일 + timelock을 요구한다. update activation은 version을 증가시키며 기존 SUSPENDED + 상태를 해제하지 않는다. +4. Manifest version, current hash, chained history hash와 pause history hash를 + 보존하고 actor, old/new, reason, effective time을 append-only event로 남긴다. +5. 배포 후 registry owner인 `CornerStoreFactory`가 resume/update schedule/cancel을 + forwarding한다. Factory owner는 production에서 외부 Safe-style governance다. + registry operator는 delay가 지난 동작의 실행과 즉시 tightening을 담당한다. + +### Consequences + +- 사고 containment는 timelock 없이 가능하지만 재개는 같은 actor가 즉시 우회할 수 + 없다. +- Factory ownership wiring을 유지하면서 governance 호출이 실제 배포에서도 + 도달 가능하다. +- chain별 delay, 실제 Safe provider, issuer-level disable과 RecipeBinding migration은 + 후속 production 설정/feature다. + +### Related Files + +- `src/registry/OperatorRegistry.sol` +- `src/registry/TokenPolicyRegistry.sol` +- `src/execution/ExecutionRouter.sol` +- `src/factory/CornerStoreFactory.sol` +- `test/integration/EmergencyPause.t.sol` +- `scripts/e2e-anvil.sh` + +## D012 — 취득 lot와 거절·감시 상태는 provider-neutral off-chain 계층으로 수렴한다 + +Date: 2026-07-22 + +### Context + +ADR-008은 acquisition source, person-group state, reject logging과 Router 밖 transfer +감시를 하나의 off-chain compliance data layer로 결정했다. 기존 `Lockup`은 만료나 +lineage 상태 없이 단일 timestamp만 읽었고, 실제 provider 계약이 없다는 이유로 +현재 문서에는 해당 결정을 여전히 open으로 표시한 곳이 남아 있었다. + +### Decision + +1. Transfer Agent별 API는 `TransferAgentProvider` adapter 뒤에 둔다. Corner Store + core는 Securitize 전용 undocumented field를 하드코딩하지 않는다. +2. per-lot 입력은 acquisition date, payment completion, source type과 lineage를 + 검증한다. 단일 holder×asset 온체인 snapshot은 lot 선택을 과대 추정하지 않도록 + 현재 lot 중 가장 늦은 유효 clock을 사용한다. +3. 온체인 `AttestedAcquisitionSource`에는 clock, observation/expiry, PII-free + source hash와 status만 저장한다. missing, broken lineage, stale과 immature는 + `Lockup`에서 서로 다른 reason으로 fail-closed한다. +4. person-group volume/holder state는 execution id로 idempotent하게 commit한다. + 동일 id+동일 내용은 no-op, 동일 id+다른 내용은 충돌로 거부한다. +5. rejected attempt와 Router 밖 transfer finding은 off-chain hash-chain audit + record로 보존한다. 이 local SDK는 tamper evidence를 제공하지만 production WORM, + retention 또는 SAR 시스템이라고 주장하지 않는다. + +### Consequences + +- mock TA로 전체 경계를 테스트할 수 있으나 실제 Securitize compatibility는 공식 + API 계약과 provider 인증을 확인하기 전까지 미구현이다. +- 보수적인 latest-lot clock은 안전하지만 일부 mature lot 매도를 과도하게 막을 수 + 있다. amount-specific lot allocation/FIFO는 provider 계약이 확정될 때 별도 + versioned adapter로 추가한다. +- PII, 원본 lot 문서와 감사 원장은 온체인에 저장하지 않는다. + +### Related Files + +- `docs/decisions/ADR-008-compliance-seam-decisions.md` +- `services/compliance-data/` +- `src/registry/AttestedAcquisitionSource.sol` +- `src/compliance/elements/Lockup.sol` diff --git a/FEATURES.md b/FEATURES.md index 4ce3c66..579aa6b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -372,6 +372,12 @@ passing 기본값은 BUIDL-like metadata + Reg D/QP/minimum-investment Manifest다. - live runner가 backend quote → CLI → Router/RFQAdapter 성공과 revoked-maker 실패를 자동 실행한다. +- `scripts/e2e-anvil.sh --mode rfq`는 AMM·lifecycle·surveillance 설명을 건너뛰고 + mock TA profile → Toolkit/CLI → backend-signed quote → protected RFQ settlement + → revoked-maker rejection만 보여주는 짧은 MVP 시연 경로를 제공한다. +- 기존 Operator dashboard는 `Operator view`(read-only snapshot)와 `RFQ demo` + (local quote request/download/CLI settlement handoff) 모드를 제공하며, browser에 + private key나 direct transaction endpoint를 추가하지 않는다. - backend는 pricing, signing과 nonce 발급만 담당하며 compliance 최종 판단을 하지 않는다. - production pricing, signer custody, persistent nonce, inventory/risk control과 hosted operation은 명시적으로 범위 밖이다. @@ -382,6 +388,7 @@ passing - `scripts/check.sh` - `scripts/e2e-anvil.sh --profile buidl-like` - `scripts/e2e-anvil.sh --profile reg-d` +- `scripts/e2e-anvil.sh --profile buidl-like --mode rfq` - `git diff --check` ### State @@ -395,6 +402,9 @@ passing - CLI smoke가 `--backend` request path를 검증하고 기존 `RFQFlow.t.sol`이 protected Router settlement의 성공/거부 경로를 검증한다. - Foundry v1.7.1 clean build에서 `buidl-like`과 `reg-d` 두 profile 모두 통과: 각각 7/7 scenarios, backend-signed quote settlement, revoked-maker 거부. +- RFQ-first dashboard/runbook: `services/operator-dashboard`의 두 모드와 local + HTTP server smoke, `--mode rfq` live E2E를 검증했다. 시연 순서는 + `docs/rfq-demo-guide.md`를 기준으로 한다. ## CLI-001 — corner-store Reference CLI @@ -639,3 +649,142 @@ passing 문서 정합화와 운영 runbook만 추가한다. contract, API 또는 production 운영 정책은 변경하지 않는다. + +## PROD-001 — Production Control Plane + +### Behavior + +- `OperatorRegistry`가 global, asset, venue pause의 단일 source of truth가 된다. +- operator는 위험을 즉시 중단할 수 있지만 unpause는 owner가 예약한 뒤 최소 + timelock이 지난 후에만 실행할 수 있다. +- `ExecutionRouter`는 compliance evaluation과 venue dispatch 전에 global, 양쪽 + asset, venue pause를 fail-closed로 검사한다. +- Manifest lifecycle은 현재 version, pending update, full manifest hash와 + append-only history hash를 보존한다. +- ACTIVE/SUSPENDED Manifest의 semantic update는 별도 pending proposal로 저장되고 + timelock 이후 승인되며, suspended asset을 update만으로 재개할 수 없다. +- lifecycle/pause 변경은 actor, old/new value, reasonCode/reasonHash와 effective time을 + event로 남긴다. + +### Verification + +- `forge fmt --check` +- `forge lint --severity high --deny warnings src` +- `forge test --offline --match-path test/unit/registry/OperatorRegistry.t.sol -vv` +- `forge test --offline --match-path test/unit/registry/TokenPolicyRegistry.t.sol -vv` +- `forge test --offline --match-path test/unit/execution/Router.t.sol -vv` +- `forge test --offline --match-path test/integration/EmergencyPause.t.sol -vv` +- `forge test --offline` +- `scripts/check.sh` + +### State + +passing + +### Notes + +- 완료 계획: `docs/exec-plans/completed/PROD-001-production-control-plane.md` +- governance owner는 외부 Safe-style multisig를 전제로 하며 컨트랙트 내부에 + n-of-m signer 로직을 구현하지 않는다. +- issuer disable, production multisig provider와 chain별 timelock 값은 후속 운영 + 설정이며, 현재 manifest에 issuer identity가 없으므로 asset pause로 fail-closed한다. + +## DATA-001 — Compliance Data Layer Foundation + +### Behavior + +- ADR-008의 Transfer Agent 경계를 provider-neutral TypeScript SDK로 제공한다. +- per-lot acquisition 입력은 lineage, 완납일과 freshness를 검증하고 보수적인 + holder×asset snapshot으로 컴파일한다. +- `Lockup`은 operator-attested snapshot의 상태와 만료를 fail-closed로 검사한다. +- 거절 시도와 router 밖 transfer finding은 PII 없이 hash-chain audit trail에 + append할 수 있다. +- person-group 단위 volume/holder state는 execution idempotency를 보장한다. +- 실제 Securitize API, WORM storage와 production surveillance hosting은 adapter + 교체 지점으로 남기며 구현되었다고 주장하지 않는다. + +### Verification + +- `forge fmt --check` +- `forge test --offline --match-path test/unit/compliance/AcquisitionSource.t.sol -vv` +- `forge test --offline --match-path test/unit/compliance/Elements.t.sol -vv` +- `cd services/compliance-data && npm test` +- `scripts/check.sh` + +### State + +passing + +### Notes + +- 완료 계획: `docs/exec-plans/completed/DATA-001-compliance-data-layer.md` +- 실제 Securitize/TA field mapping은 공식 API 계약이 제공될 때 별도 provider + adapter로 구현한다. +- 단일 holder×asset snapshot은 모든 현재 lot 중 가장 늦은 clock을 사용하므로 + amount-specific lot allocation 전까지 일부 mature lot 매도를 보수적으로 막을 수 있다. + +### Related Files + +- `services/compliance-data/` +- `src/registry/AttestedAcquisitionSource.sol` +- `src/compliance/elements/Lockup.sol` +- `test/unit/compliance/AcquisitionSource.t.sol` + +## MANIFEST-002 — RecipeBinding Manifest Migration + +### Behavior + +- 자산 Manifest는 고정 `issuanceRecipeId + fundRecipeId` 대신 bounded + `RecipeBinding[]`를 registry에 저장한다. +- `REQUIRED_BLOCKING` Recipe는 AND, 같은 `pathGroupId`의 `PATH_OPTION`은 OR, + 서로 다른 path group은 AND로 평가한다. +- `FLAG_ONLY` 실패는 거래를 막지 않고 `ComplianceDecision.flagsBitmap`과 Router + event로 노출한다. +- binding 변경은 full manifest hash 변경과 기존 timelock/version/history를 거친다. +- binding 수, recipe/version, path group과 duplicate 입력은 등록 시 fail-closed로 + 검증한다. + +### Verification + +- RecipeBinding registry/lifecycle unit tests +- REQUIRED/PATH/FLAG engine regression tests +- pair-side, stateful commit와 Router event integration tests +- `scripts/check.sh` +- `buidl-like` / `reg-d` live E2E + +### State + +passing + +### Notes + +- 완료 계획: `docs/exec-plans/completed/MANIFEST-002-recipe-binding-migration.md` +- canonical `bytes32 recipeKey` alias와 per-element enforcement override compiler는 + 별도 versioned refinement로 남긴다. + +## AMM-001 — Canonical Uniswap v3 Pool E2E + +### Behavior + +- vendored `@uniswap/v3-core` artifact로 canonical factory/pool을 배포한다. +- CREATE2 예상 주소와 factory가 생성한 pool 주소가 일치해야 한다. +- 실제 pool을 verified ERC-3643 holder, venue와 adapter allowlist에 등록한다. +- 초기화·유동성 공급 후 `ExecutionRouter → UniswapV3Adapter → pool` exact-input + swap이 실제 callback과 ERC-3643 transfer를 거쳐 성공한다. +- 미등록 pool/callback은 계속 fail-closed이고 Router/Adapter는 잔액을 보유하지 않는다. + +### Verification + +- canonical factory/pool deployment와 CREATE2 preflight integration test +- protected buy/sell, compliance rejection와 callback authorization tests +- `scripts/check.sh` + +### State + +passing + +### Notes + +- 완료 계획: `docs/exec-plans/completed/AMM-001-real-uniswap-v3-e2e.md` +- vendored Solidity source를 제품 `src/`로 복사하지 않는다. +- production fee-tier 승인, LP 운영 정책과 unified deploy CLI는 별도 후속 범위다. diff --git a/PROGRESS.md b/PROGRESS.md index 139f5f2..5497da8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -6,12 +6,12 @@ reference execution contracts와 vendored Uniswap v3 배포 도구를 포함한다. 공식 문서는 DEX-level compliance SDK, Corner Store reference DEX, -Element/Recipe/Manifest/Operator 4-Layer와 cumulative multi-Recipe 모델을 +Element/Recipe/Manifest/Operator 4-Layer와 mode-aware `RecipeBinding[]` 모델을 source of truth로 사용한다. ## Active Feature -- 없음 +없음 ## Completed @@ -34,6 +34,18 @@ source of truth로 사용한다. - `OPS-001 — High-severity Solidity Lint Gate` - `OPS-002 — Repository-wide CI Parity` - `DOC-003 — Goal Completion and Operations Alignment` +- `PROD-001 — Production Control Plane`(central global/asset/venue pause, + delayed unpause와 Manifest resume/update, monotonic version/history, + Factory governance forwarding) +- `DATA-001 — Compliance Data Layer Foundation`(provider-neutral TA lot resolver, + expiring attested acquisition snapshot, fail-closed Lockup, idempotent + person-group state와 hash-chain rejection/surveillance audit) +- `MANIFEST-002 — RecipeBinding Manifest Migration`(bounded registry-backed + bindings, required/path/flag 평가, delayed lifecycle update와 CLI/demo ABI migration) +- `AMM-001 — Canonical Uniswap v3 Pool E2E`(pinned core artifact factory/pool, + CREATE2 preflight, real mint/swap callback와 ERC-3643 protected buy/sell) +- RFQ-first MVP demo refinement(`scripts/e2e-anvil.sh --mode rfq` + existing + Operator dashboard의 `RFQ demo` mode + `docs/rfq-demo-guide.md`) - multi-venue 아키텍처와 책임 문서 작성 - Corner Store용 Uniswap v3 최소 배포 profile 분리와 테스트 - ExecutionRouter/VenueRegistry/VenueSelector와 AMM reference adapter skeleton @@ -73,10 +85,10 @@ source of truth로 사용한다. ## Next -1. RFQ production policy를 별도 feature로 분리한다: custody, partial fill, +1. 실제 TA provider API/authorization, amount-specific lot allocation과 production + WORM/indexer를 별도 refinement로 구현한다. +2. RFQ production policy를 별도 feature로 분리한다: custody, partial fill, production dealer/operator 책임. -2. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 - (C-01 Lockup은 현재 fixture-only mock acquisition source). 3. 실제 Uniswap v3 pool 배포를 demo/E2E에 연결한다(현재 AMM venue는 MockPool; `tools/deploy-v3` vendor isolation 유지). 4. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. @@ -85,6 +97,41 @@ source of truth로 사용한다. ## Last Session Summary +- RFQ MVP demo는 mock TA-seeded BUIDL-like profile, Toolkit preflight/onboard, + backend EIP-712 quote, protected Router settlement와 revoked-maker rejection을 + `--mode rfq` 한 경로로 시연한다. 기존 Operator dashboard는 read-only + Operator view와 quote request/download/CLI handoff용 RFQ demo를 구분하며, + browser private key·direct transaction 권한을 갖지 않는다. `scripts/check.sh` + (641/641 Foundry 포함)과 RFQ-only live E2E가 통과했다. +- `AMM-001`에서 vendored pinned Uniswap v3 core artifact로 canonical factory와 + pool을 배포하고 CREATE2 주소, 초기화, 실제 liquidity mint/swap callback, + Router-protected ERC-3643 buy/sell을 검증했다. Adapter는 pool token 방향과 + callback positive delta를 binding해 잘못된 token pull을 거부한다. + `scripts/check.sh`가 Foundry 641/641, 모든 service smoke와 deploy-v3 10/10을 + 통과했고 독립 리뷰 지적은 exact compliance reason 회귀로 반영했다. +- `MANIFEST-002`에서 고정 issuance/fund 두 필드 대신 bounded + `RecipeBinding[]`를 runtime source of truth로 도입했다. required AND, + path-group OR/그룹 간 AND, non-blocking flag bitmap과 deterministic failure를 + 구현하고, `FLAG_ONLY` stateful hook이 settlement를 되돌리지 못하도록 + trade-critical commit에서 분리했다. `scripts/check.sh`(Foundry 634/634, 모든 + service smoke, deploy-v3 10/10)와 `buidl-like`/`reg-d` live E2E(각 7/7 + + delayed recovery + CLI onboarding + backend RFQ)가 통과했다. +- `DATA-001`에서 per-lot acquisition/완납/lineage를 검증하는 provider-neutral SDK, + operator-attested on-chain snapshot과 missing/broken/stale/immature를 구분하는 + fail-closed Lockup을 구현했다. `SECONDARY` lot의 과거 lineage 상속을 거부하고, + cyclic lineage, holder 상태 변경, conflicting replay와 audit 변조 회귀를 고정했다. + `scripts/check.sh`(Foundry 618/618, 모든 service smoke, deploy-v3 10/10)와 + `buidl-like`/`reg-d` live E2E(각 7/7 + delayed recovery + AMM/RFQ)가 통과했다. + 실제 Securitize API, amount-specific allocation과 production WORM은 외부 계약 + 확정 전까지 후속 범위다. +- `PROD-001`에서 `OperatorRegistry`를 global/asset/venue pause source of truth로 + 만들고 Router의 nonce/evaluation 전에 fail-closed enforcement를 추가했다. + unpause와 Manifest resume/update는 owner schedule + 1일 timelock으로 분리했고, + version/history hash와 governance event를 보존한다. registry ownership이 + Factory로 이전된 실제 배포에서도 schedule이 가능하도록 governance forwarding을 + 추가했다. `scripts/check.sh`(Foundry 609/609, 모든 service smoke, deploy-v3 + 10/10)와 `buidl-like`/`reg-d` live E2E(각 7/7 + 실제 delayed resume 후 AMM/RFQ)가 + 통과했다. - `DOC-003`에서 ROADMAP의 Toolkit/API/dashboard/live E2E 완료 상태와 production 후속 범위를 정렬하고 incident-response runbook을 추가했다. 최신 main에서 `buidl-like`와 `reg-d`가 각각 7/7 scenario, Toolkit preflight/checkpoint, diff --git a/QUALITY.md b/QUALITY.md index 254ceb2..88939a5 100644 --- a/QUALITY.md +++ b/QUALITY.md @@ -2,12 +2,12 @@ | Module | Grade | Reason | Required Improvement | | --- | --- | --- | --- | -| Product documentation | B | SDK/reference DEX, 4-Layer, RFQ v1 scope와 roadmap이 대체로 정합함 | production RFQ/OrderBook, Manifest lifecycle와 법률 승인 기준 보강 | +| Product documentation | B | SDK/reference DEX, 4-Layer, RecipeBinding Manifest, RFQ와 roadmap이 대체로 정합함 | production RFQ/OrderBook와 법률 승인 기준 보강 | | Harness / agent workflow | B | HE-001, DOC-001, RFQ-001 상태·검증 이력이 존재함 | PR/CI 결과와 feature state 지속 동기화 | -| Product Solidity | B- | Compliance Core, registries, ExecutionRouter, AMM adapter와 RFQ v1 adapter가 컴파일·테스트됨 | production Manifest lifecycle, RFQ dealer/custody/cancel, OrderBook 미구현 | -| Foundry tests | B | unit/integration 테스트, RFQ failure path와 live Anvil E2E 존재 | 추가 adversarial/security tests | +| Product Solidity | B | bounded RecipeBinding Manifest, lifecycle/history, Compliance Core, Router, hardened AMM/RFQ adapter가 컴파일·테스트됨 | production custody/partial fill, LP onboarding과 OrderBook 미구현 | +| Foundry tests | B | unit/integration, canonical Uniswap v3 callback, RFQ failure path와 live Anvil E2E 존재 | 추가 adversarial/security tests | | RFQ reference service | B | EIP-712 SDK와 local demo HTTP API/CLI, nonce·입력·서명 smoke test 존재 | signer custody, persistent nonce, production pricing/inventory는 별도 feature에서 결정 | -| `tools/deploy-v3` | B | profile 단위 테스트와 문서 존재 | 자동 Anvil integration test 추가 | +| `tools/deploy-v3` | B | profile 단위 테스트와 pinned core artifact integration 존재 | unified production deployment orchestration 추가 | | CI / static analysis | B | GitHub Actions와 local check가 동일한 repository-wide gate를 실행해 Foundry, 서비스 smoke, dashboard와 deploy-v3를 검증함 | medium warning budget과 독립 보안 분석 도입 | | Security documentation | B | trust boundary, direct venue boundary와 구현 전 보안 규칙을 문서화함 | RFQ/dealer/custody 위협 모델과 production review 체크리스트 보강 | diff --git a/README.md b/README.md index 5c72b3c..cf597e8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ reference execution adapters including AMM and RFQ settlement paths. - 제3의 DEX가 재사용할 수 있는 compliance interface와 registry 모델을 제공한다. - Router를 수정하지 않고 정책과 execution Adapter를 등록·교체한다. - 자산 Manifest와 거래 context로 applicable Recipe를 식별한다. -- 여러 Recipe의 Element를 cumulative AND로 실행 전에 평가한다. +- Manifest의 `RecipeBinding[]`에 따라 필수 Recipe는 AND, 같은 path group의 + 대안 Recipe는 OR, 비차단 Recipe는 flag로 실행 전에 평가한다. - 허용된 venue adapter로 거래를 전달한다. - ERC-3643 token transfer enforcement와 Corner Store 거래 정책의 실패를 원자적으로 처리한다. @@ -58,7 +59,8 @@ reference execution adapters including AMM and RFQ settlement paths. Required tools: - Foundry stable (`forge`, `anvil`; live E2E verified with v1.7.1) -- Node.js and npm for `services/rfq`, `services/rfq-demo-backend` and `services/cli` +- Node.js and npm for the TypeScript services under `services/`, including RFQ, + CLI, Toolkit, Operator API and Compliance Data SDK - Yarn for `tools/deploy-v3` Foundry 버전을 바꾼 뒤 script broadcast에서 constructor decoding 오류가 나면 @@ -127,6 +129,15 @@ not a hosted or production RFQ operator service. scripts/e2e-anvil.sh --profile buidl-like --keep ``` +For the short RFQ-first stakeholder walkthrough, omit the AMM scenario suite: + +```shell +scripts/e2e-anvil.sh --profile buidl-like --mode rfq +``` + +Add `--keep` for an interactive follow-up; the runner restores the demo maker +after its rejection check so a new quote can be filled immediately. + `--keep` leaves both Anvil and the RFQ demo backend running. In another terminal, request and settle the quote through the protected Router path: @@ -139,6 +150,19 @@ node services/cli/dist/cli/src/index.js buy 0 --venue rfq --quote quote.json See [`services/rfq-demo-backend/README.md`](./services/rfq-demo-backend/README.md) for the complete local flow and production replacement boundaries. +### Compliance Data SDK + +`services/compliance-data` implements the provider-neutral ADR-008 foundation: +TA lot/lineage resolution, conservative acquisition snapshots, person-group +state and tamper-evident rejection/surveillance records. It does not include or +claim compatibility with an undocumented production Securitize API. + +```shell +cd services/compliance-data +npm ci +npm test +``` + ### Check All ```shell diff --git a/docs/MVP-v2-multi-venue.md b/docs/MVP-v2-multi-venue.md index 5b8462d..5f6f2a6 100644 --- a/docs/MVP-v2-multi-venue.md +++ b/docs/MVP-v2-multi-venue.md @@ -252,7 +252,7 @@ request -> 양쪽 자산의 Manifest facts와 transaction context로 applicable Recipes 식별 -> Recipe별 Element reference를 합집합으로 구성 -> 중복 Element는 동일 context에서 한 번만 평가 - -> 활성 Element를 cumulative AND로 검사 + -> RecipeBinding mode에 따라 required AND / path-group OR / flag-only로 검사 -> 허용 engine, venue, amount, version을 decision에 binding -> 등록된 Adapter 실행 -> ERC-3643 transfer enforcement @@ -303,14 +303,15 @@ Router와 ComplianceEngine의 정확한 함수 분리는 구현 단계에서 정 ```solidity struct ComplianceDecision { bool allowed; - bytes32 manifestId; - uint64 manifestVersion; - bytes32 appliedRecipesHash; + bytes32 policyId; + uint64 policyVersion; uint64 validUntil; uint256 maxAmount; uint256 allowedVenueTypes; bytes32 allowedVenuesHash; bytes32 reasonCode; + bytes32 reliedClaims; + uint256 flagsBitmap; bytes32 decisionHash; } ``` diff --git a/docs/README.md b/docs/README.md index a382aca..44ab262 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ | [`ROADMAP.md`](./ROADMAP.md) | 구현 순서, 완료 조건, blocker | Current | | [`testing.md`](./testing.md) | 테스트와 완료 기준 | Current | | [`demo.md`](./demo.md) | live Anvil E2E / demo runbook | Current | +| [`rfq-demo-guide.md`](./rfq-demo-guide.md) | RFQ-first MVP presenter guide | Current | | [`operations/incident-response.md`](./operations/incident-response.md) | 사고 대응·복구 runbook | Current | | [`security.md`](./security.md) | 보안 규칙 | Current | | [`rfq-threat-model.md`](./rfq-threat-model.md) | RFQ venue 위협 모델 | Current | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4a31d30..ec520fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,9 +16,11 @@ - Uniswap v3 vendored deployment profile 분리와 단위 테스트 - Foundry product scaffold와 공통 type/error/event/interface - Element/Recipe registry와 illustrative compliance elements/recipes -- Token policy registry와 cumulative multi-Recipe evaluation +- Token policy registry와 bounded RecipeBinding(required/path/flag) evaluation - generic ExecutionRouter, VenueRegistry, VenueSelector와 공통 Adapter interface - AMM reference adapter와 RFQ v1 reference settlement adapter +- pinned canonical Uniswap v3 factory/pool CREATE2, liquidity callback와 + Router-protected ERC-3643 buy/sell integration test - RFQ quote signer SDK, local MVP backend와 CLI/Router settlement flow - `buidl-like`/`reg-d` profile별 live Anvil deployment, Toolkit preflight/checkpoint와 protected AMM/RFQ E2E @@ -27,16 +29,21 @@ - Element/Recipe/Adapter/provider template metadata와 required-input validation - read-only Operator API, finality-aware/file-backed event index와 safe multisig-oriented dashboard +- central global/asset/venue pause enforcement와 delayed unpause, versioned + Manifest history 및 delayed semantic update control plane +- provider-neutral compliance data SDK, attested acquisition snapshot과 + hash-chain rejection/surveillance audit foundation - Foundry unit/integration tests와 전체 developer/operator service 및 vendored deploy-v3를 검증하는 repository-wide GitHub Actions gate 남은 주요 작업: -- production Asset Compliance Manifest lifecycle/schema/proposal/activation +- canonical recipe-key alias와 per-element enforcement override compiler - production legal Element 기준과 승인된 operator 입력 모델 -- acquisition/lot data source와 holding-period Recipe 활성화 조건 +- 실제 Securitize/TA provider adapter, production WORM/indexer와 amount-specific + lot allocation 정책 - RFQ production signer custody, persistent nonce, pricing/inventory와 partial-fill 정책 -- 실제 Uniswap v3 pool을 사용하는 AMM deployment/E2E +- production Uniswap v3 pool/LP onboarding과 unified deployment orchestration - Order Book matching/custody/surveillance 모델 - production TLS, secret rotation, 실제 multisig provider와 live RPC finality/recovery 운영 @@ -57,7 +64,8 @@ flowchart LR 1. mock ERC-3643 자산에 `ACTIVE` Manifest를 등록한다. 2. transaction context에 따라 복수 Recipe가 활성화된다. -3. Element 합집합이 cumulative AND로 평가된다. +3. required Recipe는 cumulative AND, 명시된 path option은 group OR로 평가되고 + non-blocking finding은 flag로 분리된다. 4. 허용된 engine의 Adapter로만 실행된다. 5. Corner Store 또는 ERC-3643 거부 시 전체 settlement가 원자적으로 실패한다. 6. 명시적 `UNREGULATED` 일반 ERC-20 public path에는 4-Layer 보장이 없고, @@ -143,8 +151,8 @@ IElement 최초 확정 전에 stateful Element commit hook을 결정한다. 자산별 규제·engine binding과 cumulative multi-Recipe evaluation을 구현한다. -Status: cumulative evaluation and token policy registry are implemented for the -reference proof; full production Manifest lifecycle/schema remains open. +Status: bounded RecipeBinding evaluation, validated lifecycle, monotonic +history/version과 timelocked semantic update control plane이 구현됨. ### Deliverables @@ -153,7 +161,8 @@ reference proof; full production Manifest lifecycle/schema remains open. - Recipe set, resale path, supported engine과 version binding - issuer-side coverage representation - applicable Recipe identification -- Element union/deduplication과 cumulative AND +- REQUIRED AND, path-group OR/group 간 AND와 FLAG_ONLY finding +- selected-path stateful commit와 duplicate Element commit 방지 - structured `ComplianceDecision` - preview/evaluate API와 audit events @@ -161,7 +170,8 @@ reference proof; full production Manifest lifecycle/schema remains open. - 한 Manifest에 복수 Recipe를 binding할 수 있다. - transaction context에 따라 Recipe subset이 활성화된다. -- 모든 applicable Recipe의 활성 Element가 cumulative AND로 평가된다. +- 모든 applicable required Recipe가 AND로 평가되고 각 path group은 하나 이상의 + 통과 경로를 요구하며 FLAG_ONLY 실패는 실행을 막지 않는다. - duplicate Element 최적화가 결과 의미를 바꾸지 않는다. - decision이 actor, asset, amount, engine/venue, Manifest version, nonce와 expiry에 바인딩된다. @@ -172,14 +182,16 @@ reference proof; full production Manifest lifecycle/schema remains open. 자산이 있으면 양쪽 regulated Manifest의 applicable Recipe를 합쳐 평가한다. - full off-chain manifest hash와 on-chain core의 version 변경이 추적된다. -### Blocking Design Decisions +### Design Decisions -1. Rule 144 holding period를 위한 acquisition/lot data source +1. acquisition/lot source와 reject audit seam은 ADR-008/D012로 결정되고 DATA-001 + foundation으로 구현되었다. 2. Manifest scope: token 또는 token×venue -3. Recipe set와 issuer coverage encoding -4. reject audit trail +3. issuer coverage encoding +4. canonical recipe-key alias와 per-element enforcement override compiler -acquisition data가 필요한 Recipe는 data source가 결정되기 전 활성화하지 않는다. +실제 Rule 144 production 활성화는 provider API, lot allocation과 운영 저장소가 +검증될 때까지 보류한다. ## Phase 3 — Execution Integration Kit @@ -225,8 +237,9 @@ Corner Store reference DEX의 구체 Venue는 공통 SDK/Router 기반 위에서 ### 4A. Uniswap v3 AMM -Status: Router-protected reference adapter와 MockPool 기반 live E2E는 구현됨. -실제 Uniswap v3 pool deployment/E2E는 후속 작업이다. +Status: Router-protected reference adapter, MockPool live demo와 pinned canonical +Uniswap v3 core factory/pool integration E2E가 구현됨. production pool/LP onboarding과 +unified deploy command는 후속 작업이다. Deliverables: @@ -348,9 +361,9 @@ Reference/demo evidence: 1. `design(rfq): remaining production RFQ policy` — custody, partial fill, signer, nonce, pricing/inventory와 operator 책임. -2. `feat(compliance): production Asset Compliance Manifest lifecycle/schema 구현` -3. `design(compliance): acquisition/lot data source와 holding-period Recipe 활성화 조건 결정` -4. `feat(amm): real Uniswap v3 pool deployment와 protected E2E 연결` +2. `feat(compliance): RecipeBinding 기반 production Asset Compliance Manifest schema/migration` +3. `feat(compliance): verified TA provider adapter와 amount-specific lot allocation` +4. `feat(amm): production pool/LP onboarding과 unified deployment 연결` 5. `feat(orderbook): matching/custody/surveillance 모델 결정 후 Order Book adapter 구현` 6. `ops(production): TLS, secret rotation, 실제 multisig provider와 live RPC finality/recovery 구현` @@ -360,11 +373,11 @@ Reference/demo evidence: | 결정 | 영향 Phase | 결정 전 기본값 | | --- | --- | --- | -| acquisition/lot source | 2 | 해당 holding-period Recipe 비활성 | -| Element commit hook | 1 | stateful Element 구현 보류 | +| production TA provider/API | 2 | mock provider + conservative snapshot만 사용 | +| amount-specific lot allocation | 2 | 모든 current lot가 mature해야 통과 | | Manifest scope | 2 | 결정 전 external API와 storage 확정 금지 | | Manifest 공개 범위 | 2, 5 | full document는 off-chain, 공개 필드는 미정 | -| reject audit trail | 2, 5 | 방식 확정 전 production claim 금지 | +| production WORM/retention provider | 2, 5 | local tamper-evident log만 사용 | | initial engine/scenario | 4 | 법률 검토된 illustrative scenario만 활성 | | production operator/governance | 5 | test-only admin, production 배포 금지 | diff --git a/docs/architecture/README.md b/docs/architecture/README.md index cd7c823..1d658da 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -51,7 +51,8 @@ ERC-3643 Token & Identity는 이 네 layer 중 하나가 아니라 그 아래에 ## Cross-Boundary Rules -- 활성화된 여러 Recipe는 cumulative AND로 평가한다. +- 활성화된 required Recipe는 AND, 같은 path group의 대안은 OR로 평가하고 + FLAG_ONLY finding은 blocking verdict와 분리한다. - 같은 context의 중복 Element는 한 번만 평가할 수 있지만 결과 의미를 바꾸지 않는다. - 발행 측에서 검증한 사실은 Manifest coverage로 표현하고 불필요한 재검증을 줄인다. - `ComplianceEngine`은 거래를 실행하지 않고 Adapter는 정책을 정의하지 않는다. diff --git a/docs/architecture/SKELETON_GUIDE.md b/docs/architecture/SKELETON_GUIDE.md index 8c17165..62191c3 100644 --- a/docs/architecture/SKELETON_GUIDE.md +++ b/docs/architecture/SKELETON_GUIDE.md @@ -87,8 +87,9 @@ **엔진 `evaluate` 내부** (`ComplianceEngine.sol`): - 양쪽 토큰 상태를 본다. 한쪽이라도 `UNKNOWN`/`SUSPENDED` → **거부(fail-closed)**. 양쪽 모두 `UNREGULATED`여야만 passthrough. 한쪽이 `ACTIVE`면 그 토큰의 Manifest로 평가. -- Manifest에서 적용 Recipe들(발행 + 조건부 fund)을 모은다 → 각 `isApplicable`로 거른다 - → 필요한 Element id들을 **합집합 + 중복제거** → 각 Element `check()` → **전부 통과(AND)**. +- Manifest의 bounded `RecipeBinding[]`를 읽고 각 `isApplicable`을 평가한다. + `REQUIRED_BLOCKING`은 AND, 같은 group의 `PATH_OPTION`은 OR, group 간에는 AND이며 + `FLAG_ONLY` 실패는 `flagsBitmap`으로만 노출한다. - 실패 시 `reasonCode = ReasonCodes.encode(recipeId, elementId, code)`로 어떤 규제·부품이 막았는지 인코딩. @@ -97,12 +98,12 @@ | 부분 | 상태 | | --- | --- | | 라우팅·게이트·nonce·재진입 가드 | **진짜 동작** | -| 다중 Recipe 누적 AND, 조건부 활성화, fail-closed, union/dedup | **진짜 동작** | +| 다중 Recipe required/path/flag 조합, 조건부 활성화, fail-closed, commit dedup | **진짜 동작** | | Registry 저장/조회, 권한 분리(owner/operator) | **진짜 동작** | -| AMM adapter ↔ pool 콜백, non-custodial(잔액 0) | **진짜 동작** (MockPool 대상) | +| AMM adapter ↔ pool 콜백, token-direction binding, non-custodial(잔액 0) | **진짜 동작** (MockPool + canonical v3 pool) | | ERC-3643 `isVerified`/`canTransfer`, OnchainID claim | **진짜 동작** (테스트에서 실제 T-REX 배포) | | Element의 법률 판정(적격투자자·제재·QP·lockup 등) | **mock** (설정 가능한 bool/주입값) | -| Uniswap v3 실제 pool 수학 | **mock** (MockPool 1:1) | +| Uniswap v3 실제 pool 수학 | **canonical artifact integration test** (interactive demo는 MockPool 1:1) | | RFQ adapter | **v1 reference 동작** (Router-only, exact-taker full-fill EIP-712 quote settlement) | | OrderBook adapter | **스텁** (revert) | | `computePoolAddress` | **스텁** (결정론적 keccak, 실제 init-code-hash 아님) | @@ -126,10 +127,13 @@ 호출 가능(operator가 카운터 직접 못 씀 = §6 불변식). 감사 로거 (`ComplianceLogger`/`ExecutionLogger`)는 아직 비인증 emit — production 연결 시 `onlyOperator` 게이트 필요(주석 표시). -4. **취득시점(Rule 144)** — `Lockup`은 `IAcquisitionSource` 주입형으로만 존재(CR-3). - 실제 acquisition registry 미구현. 통합 스택엔 미등록(단위 테스트에서만 검증). -5. **`coverageScope`(발행측 중복검사 skip), `reliedClaims`(의존 claim 기록), - `policyId`(fundRecipe 무시)** 는 구조체 필드/주석으로 자리만 있고 동작은 placeholder. +4. **취득시점(Rule 144)** — `Lockup`은 provider-neutral `IAcquisitionSource`의 + expiring snapshot을 읽고 missing/stale/broken lineage를 fail-closed한다. + `AttestedAcquisitionSource`와 mock TA fixture는 구현됐지만 실제 Securitize API, + amount-specific lot allocation과 production WORM은 미구현이다. +5. **`coverageScope`(발행측 중복검사 skip), `reliedClaims`(의존 claim 기록)** 는 + 구조체 필드/주석으로 자리만 있고 동작은 placeholder. `policyId`는 현재 양쪽 + Manifest core와 `RecipeBinding[]` hash를 포함한다. 6. **엔진은 거래 방향(buy/sell)을 구분하지 않는다.** 항상 `ctx.buyer`를 검사한다. `ctx.buyer`/`ctx.seller`는 거래 방향이 아니라 **엔진 역할 라벨**(검증 대상/상대방)이다. 7. **`VenueType` enum 순서는 load-bearing.** `ManifestCore.supportedEngines` 비트마스크가 @@ -143,8 +147,8 @@ `ElementRegistry.registerElement(id, addr)`. - **새 규제(Recipe):** Element id들을 조합한 `IRecipe` 배포 → `RecipeRegistry.registerRecipe(id, ver, addr)`. -- **토큰에 규제 적용:** `TokenPolicyRegistry.registerManifest(token, manifest)`로 - Manifest에 issuance/fund recipe id, 허용 engine, facts 등을 등록. +- **토큰에 규제 적용:** `TokenPolicyRegistry.registerManifest(token, manifest, bindings)`로 + Manifest와 bounded RecipeBinding plan, 허용 engine, facts 등을 등록. - **새 venue:** `IExecutionAdapter` 구현 → `VenueRegistry.registerVenue(...)`. 엔진은 전부 `elementOf`/`recipeOf`/`manifestOf`로 동적 조회하므로 하드코딩된 규칙 diff --git a/docs/architecture/asset-manifest.md b/docs/architecture/asset-manifest.md index 90ac0ea..a6309bf 100644 --- a/docs/architecture/asset-manifest.md +++ b/docs/architecture/asset-manifest.md @@ -39,7 +39,37 @@ Manifest 최소 상태: `UNREGULATED`일 때만 pass-through하며, 하나 이상의 regulated 자산이 있으면 해당 자산들의 `ACTIVE` Manifest를 모두 evaluation 입력으로 사용한다. -version 변경 시 기존 order/quote의 처리와 grandfather 정책을 명시해야 한다. +현재 semantic update는 별도 pending 값으로 예약되며 최소 1일 뒤에만 활성화된다. +활성화 시 version이 단조 증가하고 old/new manifest hash와 history hash가 event에 +남는다. SUSPENDED 상태에서 update를 활성화해도 상태는 SUSPENDED로 유지된다. + +## Recipe Binding Model + +Registry는 regulated token마다 최대 8개의 `RecipeBinding`을 저장한다. + +```solidity +struct RecipeBinding { + uint16 recipeId; + uint16 recipeVersion; + RecipeBindingMode mode; + uint16 pathGroupId; + uint8 priority; +} +``` + +- `REQUIRED_BLOCKING`: 적용되는 모든 binding이 통과해야 한다. +- `PATH_OPTION`: 같은 `pathGroupId` 안에서는 하나 이상 통과해야 하고, 서로 다른 + group은 모두 통과해야 한다. +- `FLAG_ONLY`: 실패해도 거래를 막지 않고 binding index에 대응하는 + `flagsBitmap` bit와 Router event를 남긴다. + +빈 plan, 8개 초과, 중복 recipe, version 0, 잘못된 path group과 blocking gate가 +전혀 없는 plan은 등록 시 거부한다. Recipe 주소와 실제 version, Recipe당 최대 32개 +Element는 평가 시 다시 fail-closed로 검증한다. binding 변경은 Manifest hash 변경, +timelock, version/history 증가를 거친 뒤에만 활성화된다. + +`ManifestCore`의 과거 issuance/fund 필드는 ABI 전환을 위한 deprecated mirror이며 +현재 Engine, Factory와 CLI의 source of truth는 registry의 `RecipeBinding[]`다. ## Responsibility Boundary @@ -63,6 +93,7 @@ hot path에 필요한 compact core만 온체인에 둔다. 법률 문서, 심사 - pair 거래에서 양쪽 자산의 classification과 regulated Manifest를 누락하지 않는다. - Recipe set, version, engine과 scope가 decision에 바인딩된다. - full manifest hash가 변경되면 새로운 version 또는 명시적 update가 필요하다. +- ACTIVE/SUSPENDED core fact를 직접 덮어써 timelock을 우회할 수 없다. - issuer coverage는 검증된 범위보다 넓게 해석하지 않는다. - 명시적 `UNREGULATED` public path에는 SDK compliance 보장을 표시하지 않는다. - Manifest와 `UNREGULATED` 분류가 모두 없으면 fail-closed한다. @@ -73,11 +104,15 @@ hot path에 필요한 compact core만 온체인에 둔다. 법률 문서, 심사 - Manifest는 복수 Recipe orchestration의 입력이다. - full data는 off-chain, compact core와 hash는 on-chain을 기본 방향으로 한다. - 발행자 선언과 DEX 검토·승인 경계를 기록한다. +- critical lifecycle state는 `TokenPolicyRegistry`에 보존하며 full document는 + `fullManifestHash`로 anchor한다. +- registry ownership이 Factory로 이전된 배포에서는 외부 governance가 Factory의 + forwarding API로 resume/update를 예약하고 operator가 delay 후 실행한다. ## Open Decisions - token 단위 또는 token×venue 단위 scope - 공개 필드와 비공개 자료의 경계 -- Recipe set encoding과 version migration - coverage field와 claim lookup 최적화 -- Manifest proposal/approval role 구성 +- token 단위 version 변경이 기존 signed order/quote에 미치는 정책 +- canonical `bytes32 recipeKey` alias와 per-element enforcement override compiler diff --git a/docs/architecture/compliance-policy.md b/docs/architecture/compliance-policy.md index 6c18445..4d92fa1 100644 --- a/docs/architecture/compliance-policy.md +++ b/docs/architecture/compliance-policy.md @@ -3,7 +3,7 @@ ## Responsibility 이 경계는 transaction context와 Asset Manifest를 해석해 적용 Recipe를 식별하고, -활성 Element를 cumulative AND로 평가해 구조화된 decision을 만든다. +`RecipeBinding`의 blocking/path/flag 의미로 평가해 구조화된 decision을 만든다. 핵심 질문: @@ -32,8 +32,9 @@ Element 추가 기준: 3. 거래 전, 거래 시점, 거래 후 중 언제 작동하는가? 4. 현재 거래만 보는가, 누적 상태를 보는가? -stateful Element의 commit hook은 열린 interface 결정이다. 읽기 검사와 상태 갱신을 -분리해야 하며 실패한 settlement가 누적 상태를 남겨서는 안 된다. +stateful Element는 읽기 검사와 settlement 후 `commit()`을 분리한다. 온체인 commit은 +Router 성공 경로에서만 호출하고, off-chain person-group state는 execution id에 +동일 내용이면 no-op, 다른 내용이면 reject하는 idempotency를 적용한다. ## Recipe @@ -43,12 +44,16 @@ Recipe는 하나의 법률효과를 표현하는 Element 집합과 활성화 log 평가 규칙: - Manifest와 transaction context로 applicable Recipe를 식별한다. -- 모든 applicable Recipe의 Element reference를 합집합으로 만든다. -- 동일 context의 중복 Element는 한 번만 실행할 수 있다. -- 활성화된 모든 Element를 통과해야 한다. +- `REQUIRED_BLOCKING`은 모두 통과해야 한다. +- 같은 `pathGroupId`의 `PATH_OPTION`은 하나 이상 통과해야 하고 group 간에는 AND다. +- `FLAG_ONLY` 실패는 거래를 막지 않고 stable binding-index bit로 노출한다. + 해당 binding의 stateful hook도 trade-critical `commit()`에서 실행하지 않으며, + 비차단 관측은 Router event와 off-chain consumer가 처리한다. +- post-trade stateful commit은 선택된 path만 반영하고 같은 asset/Element를 중복 + 반영하지 않는다. - 어떤 Recipe 또는 Element가 실패했는지 구조화된 reason으로 반환한다. -Recipe 하나를 골라 나머지를 무시하거나 first-success 방식으로 평가하지 않는다. +Manifest에 선언되지 않은 Recipe를 암묵적으로 선택하지 않는다. ## Manifest Integration @@ -87,12 +92,12 @@ struct ComplianceDecision { uint256 allowedVenueTypes; bytes32 allowedVenuesHash; bytes32 reasonCode; + bytes32 reliedClaims; + uint256 flagsBitmap; bytes32 decisionHash; } ``` -정확한 ABI는 Foundation feature에서 확정한다. - ## Trust Boundaries - 법률 해석과 production 기준의 정확성은 승인된 Recipe/Manifest와 운영주체에 @@ -104,7 +109,7 @@ struct ComplianceDecision { ## Invariants -- applicable Recipe는 cumulative AND로 평가한다. +- RecipeBinding의 required/path/flag 조합 의미를 fail-closed로 평가한다. - `tokenIn`과 `tokenOut` 양쪽의 classification과 Manifest를 평가한다. - 양쪽 모두 명시적 `UNREGULATED`일 때만 public pass-through를 허용한다. - 하나 이상의 regulated 자산이 있으면 모든 regulated Manifest의 applicable @@ -121,18 +126,20 @@ struct ComplianceDecision { - Element/Recipe/Manifest/Operator 이름 기반 4-Layer를 사용한다. - Recipe는 법률효과 하나를 표현한다. -- applicable Recipe는 cumulative AND로 평가한다. +- registry-backed bounded `RecipeBinding[]`를 사용한다. - Asset Manifest가 기존 single Recipe mapping/Token Policy 역할을 확장한다. - 온체인은 검증·게이팅·집행, 오프체인은 재량 판단·민감 정보·대량 연산을 맡는다. - 발행 측 사실은 coverage delta 방식으로 재사용한다. +- ADR-008의 acquisition lot, reject logging과 Router 밖 surveillance는 + provider-neutral off-chain data layer로 연결한다. 온체인에는 expiring snapshot과 + PII-free hash만 둔다. ## Open Decisions -- acquisition/lot data source -- stateful Element commit hook -- Manifest/Recipe set encoding과 duplicate Element key +- canonical recipe key alias와 per-element enforcement override compiler - issuer coverage encoding -- reject audit trail +- production TA API/authorization, amount-specific lot allocation +- production WORM/retention과 surveillance hosting - production Element/Recipe 목록과 법률 승인 ## References diff --git a/docs/architecture/deployment-operations.md b/docs/architecture/deployment-operations.md index 890fa5d..21bdf12 100644 --- a/docs/architecture/deployment-operations.md +++ b/docs/architecture/deployment-operations.md @@ -44,6 +44,10 @@ Corner Store 프로필 호출 API는 실제 AMM 통합 배포 소비자가 생 9. immutable manifest 확정 owner-only 설정과 검증이 끝나기 전에 ownership을 이전하지 않는다. +현재 reference wiring은 `TokenPolicyRegistry`와 `VenueRegistry` ownership을 +`CornerStoreFactory`로 이전하고, Factory owner를 외부 governance 경계로 사용한다. +따라서 Manifest resume/update 예약은 Factory forwarding API를 통해야 하며, +operator는 timelock 뒤 registry에서 실행만 담당한다. ## Deployment Manifest Requirements diff --git a/docs/compliance/element-data-source-matrix.md b/docs/compliance/element-data-source-matrix.md index 2847719..4bb57f5 100644 --- a/docs/compliance/element-data-source-matrix.md +++ b/docs/compliance/element-data-source-matrix.md @@ -23,7 +23,7 @@ | **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년 기간 기산점 | +| **C-01 Rule 144 Lockup** | `AttestedAcquisitionSource`의 expiring holder×asset snapshot + mock TA lot resolver | ADR-008의 Transfer Agent adapter; 실제 Securitize API mapping은 미검증 | off-chain per-lot lineage/완납일 검증 → conservative snapshot hash attestation → `Lockup` fail-closed | **foundation 구현, production provider blocked.** Q: 공식 API field/auth, amount-specific lot allocation(FIFO 등), retention/WORM provider | | **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 노트 참조) | @@ -48,7 +48,8 @@ 표준으로 한다. 2. **기술 선행 작업** (법무 무관하게 진행 가능): B-02 introspection 전환 검증, A-03 4-원자 element 분해 설계, A-05 fail-closed 반전 설계. -3. **결정 대기**: C-01 acquisition 데이터 소스 (ROADMAP Decision Backlog). +3. **provider refinement**: C-01의 실제 TA API mapping, amount-specific lot allocation과 + production audit storage를 검증한다(ADR-008/D012). 관련 문서: D008/D009(`DECISIONS.md`), `docs/demo.md`(mock vs real 경계), `docs/compliance/04-element-interface.md`(인터페이스·택소노미), diff --git a/docs/demo.md b/docs/demo.md index b375499..d09b11e 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -13,6 +13,7 @@ This is feature `E2E-001` (see `FEATURES.md`). ```sh scripts/e2e-anvil.sh # BUIDL-like default: scenarios + backend/CLI RFQ flow scripts/e2e-anvil.sh --profile reg-d +scripts/e2e-anvil.sh --mode rfq # concise mock TA → SDK/CLI → backend RFQ walkthrough scripts/e2e-anvil.sh --port 8600 scripts/e2e-anvil.sh --keep # leave Anvil running afterwards (attach a UI / continue interactively) ``` @@ -21,6 +22,12 @@ scripts/e2e-anvil.sh --keep # leave Anvil running afterwards (attach a UI / - Exit code is non-zero if any scenario fails (`DemoScenarios` reverts). - `--keep` prints the Anvil/backend PIDs and leaves both processes up; stop them with the printed `kill ` commands. +- With `--keep`, the runner restores the maker after proving the revoked-maker + rejection, so the next RFQ quote can be settled interactively without reset. +- `--mode rfq` skips the AMM, lifecycle and surveillance walkthrough. It keeps + the MVP path focused on a mock-TA-seeded investor receiving a backend-signed + RFQ quote, settling through `ExecutionRouter → RFQAdapter`, and rejecting the + same flow after the maker is revoked. Under the hood the runner executes two forge scripts and one backend/CLI stage: @@ -105,15 +112,17 @@ REAL, genuinely enforced on-chain: 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`. +- The fast interactive demo intentionally uses the deterministic in-repo + `MockPool` (1:1 rate). Canonical Uniswap v3 factory/pool behavior is separately + automated in `test/integration/RealUniswapV3.t.sol`, while the vendored + `tools/deploy-v3` infrastructure remains isolated. A unified deployment command + for both stacks is still a follow-up. 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 + operator-settable mocks, and the C-01 Rule 144 lockup reads an injected, + expiring `AttestedAcquisitionSource` snapshot seeded from mock TA data. 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 + setters, provider-neutral mock acquisition snapshot) 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). diff --git a/docs/exec-plans/completed/AMM-001-real-uniswap-v3-e2e.md b/docs/exec-plans/completed/AMM-001-real-uniswap-v3-e2e.md new file mode 100644 index 0000000..890ce5c --- /dev/null +++ b/docs/exec-plans/completed/AMM-001-real-uniswap-v3-e2e.md @@ -0,0 +1,37 @@ +# AMM-001 — Canonical Uniswap v3 Pool E2E + +## Goal + +현재 MockPool 기반 AMM 증명에 canonical Uniswap v3 factory/pool의 실제 CREATE2, +mint callback과 swap callback 경로를 추가한다. + +## In Scope + +- isolated deploy-v3 dependency의 canonical core artifact 사용 +- factory/pool deployment와 CREATE2 address preflight +- ERC-3643 pool identity onboarding, pool initialization과 test liquidity +- Router-protected exact-input buy/sell 및 atomic rejection regression +- callback origin, adapter non-custody와 documentation updates + +## Out of Scope + +- vendored Uniswap source의 제품 코드 복사 또는 수정 +- production LP custody, fee-tier/legal approval와 price/oracle policy +- unified multi-module deployment CLI와 testnet/mainnet deployment +- standard pool direct-call을 막는다는 보장 + +## Steps + +1. canonical artifact를 사용하는 deterministic factory/pool fixture를 만든다. +2. 실제 pool에 ERC-3643 identity와 liquidity를 준비한다. +3. Router/Adapter buy·sell 및 rejection/callback 회귀를 추가한다. +4. dependency bootstrap과 developer/runbook 문서를 정렬한다. +5. targeted tests와 repository-wide check를 통과한다. + +## Completion Evidence + +- computed CREATE2 pool address equals the deployed address. +- canonical pool mint/swap callbacks move real ERC-3643 and quote balances. +- compliance rejection occurs before pool balances move. +- unregistered callback/pool cannot pull funds. +- Router and Adapter finish swaps without custody balances. diff --git a/docs/exec-plans/completed/DATA-001-compliance-data-layer.md b/docs/exec-plans/completed/DATA-001-compliance-data-layer.md new file mode 100644 index 0000000..cb77918 --- /dev/null +++ b/docs/exec-plans/completed/DATA-001-compliance-data-layer.md @@ -0,0 +1,47 @@ +# DATA-001 — Compliance Data Layer Foundation + +## Goal + +ADR-008의 네 seam을 하나의 재사용 가능한 기반으로 연결한다. Transfer Agent lot +입력을 검증·컴파일하고, 온체인 Lockup이 신뢰할 수 있는 snapshot을 fail-closed로 +소비하며, 거절·우회·stateful counter를 off-chain에서 감사 가능하게 보존한다. + +## In Scope + +- provider-neutral TA lot interface와 deterministic lineage/clock resolver +- operator-attested on-chain acquisition snapshot source +- missing, stale, broken-lineage, immature Lockup reason 분리 +- hash-chained rejection/surveillance audit trail +- person-group rolling volume와 holder-state idempotency +- package smoke, Foundry unit test, integrated check와 source-of-truth 문서 정렬 + +## Out of Scope + +- undocumented Securitize Connect API 호출 또는 compatibility claim +- production WORM vendor, hosted indexer, SAR filing과 PII 저장 +- 법률 미확정 FPI holder threshold와 controlled-venue 선택 +- RFQ custody, real Uniswap v3와 Order Book + +## Steps + +1. Existing `IAcquisitionSource` regression behavior를 테스트로 고정한다. +2. lot resolver와 audit/state SDK를 구현한다. +3. attested snapshot contract와 richer Lockup failure path를 구현한다. +4. demo fixture와 문서를 새 boundary에 맞춘다. +5. targeted test, repository check와 security review를 통과한다. + +## Completion Evidence + +- valid inherited lot가 lineage clock을 승계한다. +- missing/cyclic lineage와 stale snapshot은 fail-closed한다. +- duplicate execution commit은 no-op이고 conflicting replay는 거부된다. +- audit record 변조가 hash-chain validation에서 탐지된다. +- real provider가 없어도 mock TA 입력으로 end-to-end snapshot/Lockup test가 가능하다. + +## Result + +- Status: completed +- `scripts/check.sh`: Foundry 618/618, 모든 Node smoke, deploy-v3 10/10 통과 +- live E2E: `buidl-like`, `reg-d` 각각 7/7 및 delayed recovery/AMM/RFQ 통과 +- independent review의 secondary-lineage lockup bypass와 test-gap 지적을 수정함 +- 미검증 범위: 실제 Securitize API, amount-specific lot allocation, production WORM diff --git a/docs/exec-plans/completed/MANIFEST-002-recipe-binding-migration.md b/docs/exec-plans/completed/MANIFEST-002-recipe-binding-migration.md new file mode 100644 index 0000000..713fbb4 --- /dev/null +++ b/docs/exec-plans/completed/MANIFEST-002-recipe-binding-migration.md @@ -0,0 +1,39 @@ +# MANIFEST-002 — RecipeBinding Manifest Migration + +## Goal + +ADR-007의 multi-Recipe 결정을 실제 registry/engine ABI로 옮기고 고정된 +`issuanceRecipeId + fundRecipeId` 한계를 제거한다. + +## In Scope + +- bounded `RecipeBinding[]` 저장, 조회와 lifecycle update +- REQUIRED AND, path-group OR/그룹 간 AND, FLAG_ONLY non-blocking finding +- deterministic blocking reason selection과 binding-index flag bitmap +- Factory, demo, CLI, Toolkit ABI/config와 tests migration +- pair evaluation와 stateful commit 보존 + +## Out of Scope + +- canonical `bytes32 recipeKey` alias registry +- per-element default action/override compiler +- pending operator-review settlement +- production legal Recipe 승인 + +## Steps + +1. 기존 two-recipe 동작과 새 binding truth table을 regression test로 고정한다. +2. core type, registry lifecycle와 Factory ABI를 migration한다. +3. engine evaluation/commit을 bounded binding plan으로 교체한다. +4. CLI/demo/config/docs를 새 ABI에 맞춘다. +5. targeted/full tests와 두 live profile E2E를 통과한다. + +## Completion Evidence + +- required failure blocks and all required pass. +- each path group requires one passing applicable option. +- flag-only failure sets a stable bit without blocking. +- flag-only stateful hooks cannot enter the trade-critical commit path. +- invalid/oversized/duplicate bindings and version mismatch fail closed. +- delayed binding update changes version/history only after activation. +- existing BUIDL-like and Reg D AMM/RFQ flows remain green. diff --git a/docs/exec-plans/completed/PROD-001-production-control-plane.md b/docs/exec-plans/completed/PROD-001-production-control-plane.md new file mode 100644 index 0000000..e847062 --- /dev/null +++ b/docs/exec-plans/completed/PROD-001-production-control-plane.md @@ -0,0 +1,57 @@ +# PROD-001 — Production Control Plane + +## Objective + +ADR-007의 governance/lifecycle 결정을 현재 reference registry/router에 반영해, +위험 중단은 즉시 가능하고 재개·semantic Manifest update는 timelock을 통과하며 +모든 변경이 append-only hash/event로 추적되는 production control-plane baseline을 +만든다. + +## In Scope + +- `OperatorRegistry` global/asset/venue pause state +- immediate pause, owner-scheduled timelocked unpause, cancellation +- `ExecutionRouter` central pause enforcement +- Manifest monotonic version과 lifecycle history hash +- ACTIVE/SUSPENDED Manifest pending update와 delayed activation +- actor/old/new/reason/effective-time events +- unit/integration regression tests와 source-of-truth 문서 정렬 + +## Out of Scope + +- Safe 자체의 signer/threshold 구현 +- production multisig provider 또는 chain-specific deployment +- issuer identity schema와 issuer-level pause +- legal Element 값, Securitize API, acquisition lot data +- RFQ custody/partial fill, real Uniswap v3, Order Book + +## Constraints + +- 기존 initial register → approve 흐름과 ABI는 가능한 한 유지한다. +- pause는 tightening이므로 operator 즉시 실행을 허용한다. +- unpause와 active Manifest semantic update는 owner + timelock을 요구한다. +- suspended Manifest update는 suspension을 해제하지 않는다. +- full legal document는 off-chain에 두고 hash만 온체인에 보존한다. + +## Test Spec + +1. global/asset/venue pause가 Router 실행을 각각 거부한다. +2. operator가 pause할 수 있지만 즉시 unpause할 수 없다. +3. owner가 unpause를 예약하고 delay 후 실행할 수 있다. +4. pending unpause는 재-pause 또는 명시적 cancel로 무효화된다. +5. initial Manifest는 version 1로 시작하고 기존 lifecycle이 유지된다. +6. ACTIVE/SUSPENDED update는 pending으로 저장되고 delay 전 승인되지 않는다. +7. update activation은 version/hash/history hash를 변경하고 old/new를 event에 남긴다. +8. suspended Manifest update는 계속 SUSPENDED다. +9. invalid/empty pending update와 권한 없는 변경은 fail-closed한다. +10. 전체 Foundry와 repository check가 통과한다. + +## Completion + +- 위 동작과 테스트가 모두 통과했다. +- `FEATURES.md`, `PROGRESS.md`, architecture/security/operations 문서를 실제 구현과 + 정렬했다. +- `scripts/check.sh`: Foundry 609/609, service smoke 전체, deploy-v3 10/10 통과. +- `buidl-like`와 `reg-d`: 각 7/7 scenario, 실제 1일 timelock resume 뒤 AMM settlement, + backend RFQ success/revoked-maker rejection 통과. +- 관련 없는 코드와 scratch 파일은 변경 세트에 포함하지 않는다. diff --git a/docs/operations/incident-response.md b/docs/operations/incident-response.md index 61b2cbe..3fa96dc 100644 --- a/docs/operations/incident-response.md +++ b/docs/operations/incident-response.md @@ -32,17 +32,24 @@ reason을 기록한다. private key, API token, claim 원문이나 PII는 ticket 1. 영향 범위를 모르면 fail-closed로 취급한다. 2. RFQ signer 또는 backend가 의심되면 새 quote 발급을 중지하고 maker approval을 철회한다. -3. venue 단위 사고는 `OperatorRegistry.setVenueSuspended`로 차단한다. -4. 자산·Manifest 단위 사고는 `TokenPolicyRegistry.suspendManifest`로 차단한다. -5. ERC-3643 token/identity/claim provider 사고는 해당 외부 운영자에게 pause, freeze, +3. 전체 실행을 즉시 중단해야 하면 `OperatorRegistry.setGlobalPaused(true, reason)`을 + 사용한다. +4. venue 단위 사고는 `OperatorRegistry.setVenueSuspended(venue, true, reason)`로 + 차단한다. +5. 자산 단위 사고는 `OperatorRegistry.setAssetSuspended(token, true, reason)`으로 + Router 실행을 차단하고, Manifest 자체가 의심되면 + `TokenPolicyRegistry.suspendManifest`도 함께 사용한다. +6. ERC-3643 token/identity/claim provider 사고는 해당 외부 운영자에게 pause, freeze, issuer revocation을 요청한다. Corner Store가 외부 trust boundary를 소유한다고 가정하지 않는다. -6. direct adapter 호출로 우회하지 않는다. 모든 복구 시험도 승인된 Router 경로를 +7. direct adapter 호출로 우회하지 않는다. 모든 복구 시험도 승인된 Router 경로를 사용한다. -현재 reference contracts에는 ADR-007이 목표로 하는 central `PauseController`가 아직 -없다. 전역 차단이 필요하면 등록된 regulated asset과 venue를 모두 suspend하고, -누락 여부를 deployment artifact와 Operator API snapshot으로 대조한다. +`OperatorRegistry`가 global/asset/venue pause의 중앙 source of truth이며 Router는 +nonce 소비와 compliance evaluation 전에 세 범위를 모두 검사한다. 중단은 operator가 +즉시 수행할 수 있지만, 재개는 owner가 예약하고 최소 1일 timelock 뒤 owner가 +실행한다. Manifest 재개는 Factory owner가 예약하고 registry operator가 delay 뒤 +실행한다. ## 3. Evidence preservation @@ -80,7 +87,9 @@ recipe 제거와 unpause는 외부 multisig 승인과 적용 가능한 timelock 5. `scripts/check.sh`가 통과했다. 6. 영향 profile의 `scripts/e2e-anvil.sh --profile `이 7/7 scenario와 protected RFQ success/rejection path를 통과했다. -7. 한 자산·venue부터 단계적으로 resume하고 Operator API events/metrics를 관찰한다. +7. global/asset/venue unpause 또는 Manifest resume를 예약하고 timelock 동안 새 증거와 + 취소 필요성을 재검토한다. +8. 한 자산·venue부터 단계적으로 resume하고 Operator API events/metrics를 관찰한다. ## 6. Post-incident diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md index c4777f2..3b7afe7 100644 --- a/docs/product-specs/index.md +++ b/docs/product-specs/index.md @@ -13,8 +13,8 @@ | [`rfq-backend-sdk-and-demo.md`](./rfq-backend-sdk-and-demo.md) | RFQ backend SDK와 MVP demo backend 계획 | Current | DOC-001에서 연구·회의 입력을 반영했다. 내용이 다를 경우 위 current 문서를 source -of truth로 사용한다. acquisition registry, Element commit hook과 reject logging은 -아직 열린 결정이다. +of truth로 사용한다. acquisition/state/reject/surveillance seam은 ADR-008/D012를 +따르며, 실제 provider API와 production WORM/hosting은 refinement blocker다. 제품 결정을 변경할 때는 `DECISIONS.md`와 관련 source-of-truth 문서를 함께 갱신한다. diff --git a/docs/rfq-demo-guide.md b/docs/rfq-demo-guide.md new file mode 100644 index 0000000..8807d87 --- /dev/null +++ b/docs/rfq-demo-guide.md @@ -0,0 +1,74 @@ +# RFQ MVP Demo Guide + +This guide demonstrates the Corner Store MVP without a real TA/Securitize +integration or AMM execution. The run uses the BUIDL-like ERC-3643 profile, +mock TA-seeded investor facts, a local RFQ quote backend and the protected +Router settlement path. + +## One-command demo + +```sh +scripts/e2e-anvil.sh --profile buidl-like --mode rfq +``` + +The command exits non-zero on failure. A successful run proves, in order: + +1. the selected asset profile is valid for the deployed stack; +2. the Toolkit preflight and checkpoint accept its configuration; +3. the CLI onboards the ERC-3643 asset and policy Manifest; +4. the RFQ backend creates an EIP-712-signed quote; +5. the CLI settles that quote only through `ExecutionRouter → RFQAdapter`; +6. revoking the maker prevents a newly signed quote from settling. + +## Presenter flow + +For an interactive session, leave Anvil and the demo backend running: + +```sh +scripts/e2e-anvil.sh --profile buidl-like --mode rfq --keep +``` + +Then start the existing operator dashboard in a second terminal and open +`http://127.0.0.1:8790`: + +```sh +npm run start --prefix services/operator-dashboard +``` + +Select **RFQ demo**, click **Check backend**, then click **Run compliant RFQ +trade**. The four visible stages show the mock TA profile, EIP-712 quote, +Router policy decision and on-chain ERC-3643 balance change. Click **Revoke +maker & retry** to show the same flow being rejected. The browser never holds +a private key. + +If you prefer the terminal instead of the dashboard, request and settle a new +quote with: + +```sh +node services/cli/dist/cli/src/index.js \ + --rpc http://127.0.0.1:8545 \ + --artifact deployments/anvil-e2e.json \ + rfq-quote --backend http://127.0.0.1:8787 --amount-in 5000000 --out quote.json + +node services/cli/dist/cli/src/index.js \ + --rpc http://127.0.0.1:8545 \ + --artifact deployments/anvil-e2e.json \ + buy 0 --venue rfq --quote quote.json +``` + +What to say while showing it: + +- “TA/Securitize is represented by a mock trusted source in this MVP; real + provider integration is intentionally not claimed.” +- “The backend only prices and signs. It cannot approve a transaction.” +- “The settlement contract evaluates the latest policy at fill time, then + delivers the ERC-3643 token only through the Router.” +- “When the maker is revoked, a newly signed quote is rejected. A signature + alone is not permission to trade.” + +## Scope boundary + +This is a local Anvil demonstration. The fixed-price backend, deterministic +maker account and mock TA data are not production services. Production signer +custody, persistent nonce storage, pricing/inventory, real TA integration and +operator authentication remain separate work. diff --git a/docs/security.md b/docs/security.md index 8e6169b..1c15465 100644 --- a/docs/security.md +++ b/docs/security.md @@ -76,6 +76,10 @@ venue/adapter에만 실행을 위임하며, 성공 후 stateful compliance `comm 변경은 실행 권한과 분리한다. - privileged action은 명시적인 owner/role 검사를 가져야 한다. - production multisig와 governance는 외부 운영 결정 전 임의로 확정하지 않는다. +- 위험 중단(global/asset/venue pause, Manifest suspend)은 operator가 즉시 수행한다. + unpause, Manifest resume와 semantic update는 owner 예약과 timelock을 거친다. +- 배포 후 `TokenPolicyRegistry.owner()`가 Factory인 구조에서는 governance가 Factory + forwarding API를 사용한다. registry를 EOA가 직접 소유한다고 가정하지 않는다. ## Input Validation @@ -104,7 +108,8 @@ venue/adapter에만 실행을 위임하며, 성공 후 stateful compliance `comm - Uniswap-style callback은 등록되었거나 계산으로 검증한 pool에서만 수락한다. callback `data`가 payer/token을 포함하더라도 callback origin 검증 없이 신뢰하지 - 않는다. + 않는다. positive delta, pool의 canonical token0/token1과 요청 token 방향도 서로 + 일치해야 하며 다른 token을 `transferFrom`하도록 바꾼 callback은 거부한다. - Pool/venue 등록은 compliance 보장의 일부다. 잘못된 venue 또는 악성 adapter가 등록되면 Router를 타더라도 settlement 결과가 왜곡될 수 있으므로 governance와 preflight 검증 대상이다. @@ -141,7 +146,11 @@ venue/adapter에만 실행을 위임하며, 성공 후 stateful compliance `comm - 성공한 regulated evaluation은 Manifest version과 applied Recipe set을 추적할 수 있어야 한다. - revert 시 event가 사라지는 특성을 고려한 reject logging 정책은 별도 설계 - 결정으로 관리한다. + 결정(ADR-008)에 따라 off-chain hash-chain audit에 기록한다. local hash-chain은 + tamper evidence일 뿐 production WORM/retention을 대체하지 않는다. +- acquisition 원본 lot, identity와 provider payload는 온체인에 기록하지 않는다. + `AttestedAcquisitionSource`에는 PII-free source hash, conservative clock, freshness와 + status만 기록하고 stale/missing/broken lineage를 fail-closed한다. ## Dependency Policy diff --git a/docs/testing.md b/docs/testing.md index aca2245..09ce1b7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,6 +47,18 @@ Backend smoke는 ephemeral HTTP server의 health/quote API, fixed-rate pricing, maker signature, monotonic nonce와 numeric amount 거부를 검증한다. CLI smoke는 backend quote request path와 기존 quote-file/서명 검증 경로를 함께 검증한다. +Compliance data SDK smoke test: + +```sh +cd services/compliance-data +npm ci +npm test +``` + +TA lot lineage/완납 clock, conservative snapshot, broken-lineage fail-closed, +idempotent person-group commit, rolling volume/holder counts와 hash-chain 변조 탐지를 +검증한다. + Vendored deploy tool 테스트: ```sh @@ -59,7 +71,10 @@ yarn test Foundry integration tests는 mock/ERC-3643 fixture를 사용해 regulated swap, multi-Recipe, surveillance, emergency pause와 invariant path를 검증한다. `tools/deploy-v3`의 Corner Store profile은 unit test로 구성과 순서를 검증하며, -과거 수동 Anvil 배포 검증 기록은 `tools/deploy-v3/CORNER_STORE_PROFILE.md`에 있다. +canonical Uniswap v3 integration test는 같은 pinned package artifact로 factory와 +pool을 배포해 CREATE2, mint/swap callback과 실제 ERC-3643 transfer를 검증한다. +따라서 fresh checkout에서는 `tools/deploy-v3`의 `yarn install --frozen-lockfile`이 +먼저 필요하며 `scripts/check.sh`가 이를 자동 bootstrap한다. 자동화된 live Anvil deployment/E2E는 `scripts/e2e-anvil.sh`로 제공된다(아래 E2E Tests 및 `docs/demo.md` 참조). @@ -86,7 +101,7 @@ scripts/e2e-anvil.sh --keep # 이후 Anvil을 계속 실행(인터랙티브 - 허용된 거래의 실행 성공 - applicable Recipe 중 하나의 Element 거부에 따른 원자적 실패 -- 여러 Recipe의 cumulative AND와 중복 Element 실행 의미 +- RecipeBinding의 REQUIRED/PATH/FLAG truth table과 stateful commit 중복 방지 - Manifest lifecycle, version과 supported engine binding - ERC-3643 transfer 거부의 원자적 실패 - 지원 Router 경로와 직접 venue 호출의 보장 차이 @@ -106,6 +121,7 @@ scripts/check.sh 이 명령은 현재 저장소에서 지원하는 format, lint, build와 test를 순서대로 실행한다. 현재 포함 범위는 Foundry fmt/lint/build/test, RFQ SDK·demo backend·CLI·Toolkit, +Compliance Data SDK, Operator API/dashboard smoke, vendored deploy-v3 test와 whitespace check다. GitHub Actions도 동일한 스크립트를 실행한다. Node 서비스는 lockfile 기반 `npm ci`를 사용하고, vendored deploy-v3는 `yarn.lock` 기반 설치 후 테스트한다. diff --git a/foundry.toml b/foundry.toml index f7d5270..2c499bc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,4 +6,7 @@ solc = "0.8.17" optimizer = true optimizer_runs = 200 via_ir = false -fs_permissions = [{ access = "read-write", path = "./deployments" }] +fs_permissions = [ + { access = "read-write", path = "./deployments" }, + { access = "read", path = "./tools/deploy-v3/node_modules/@uniswap/v3-core/artifacts" }, +] diff --git a/script/DemoScenarios.s.sol b/script/DemoScenarios.s.sol index 70d3805..3aef8ea 100644 --- a/script/DemoScenarios.s.sol +++ b/script/DemoScenarios.s.sol @@ -21,6 +21,8 @@ import { ComplianceDecision, ManifestCore, PolicyStatus, + RecipeBinding, + RecipeBindingMode, VenueType, FlowType } from "../src/types/ComplianceTypes.sol"; @@ -84,10 +86,13 @@ contract DemoScenarios is Script, DemoConstants { _scenario1_onboarding(); _scenario2_compliantTrade(); _scenario3_elementRejection(); - _scenario4_lifecycle(); _scenario5_rfq(); _scenario6_surveillance(); _scenario7_bypass(); + // Keep the asset suspended with a pending recovery at the end of this + // broadcast. scripts/e2e-anvil.sh advances the real Anvil clock, then + // executes the delayed resume and proves settlement through the CLI. + _scenario4_lifecycle(); _summary(); } @@ -104,6 +109,7 @@ contract DemoScenarios is Script, DemoConstants { ); ManifestCore memory m = _baseManifest(); + RecipeBinding[] memory bindings = _baseBindings(); VenueConfig memory ammCfg = VenueConfig({ venueType: VenueType.AMM, @@ -115,12 +121,13 @@ contract DemoScenarios is Script, DemoConstants { }); vm.broadcast(deployerPk); - factory.registerRWAToken(address(rwa), m, pool, ammCfg); + factory.registerRWAToken(address(rwa), m, bindings, pool, ammCfg); ManifestCore memory stored = policyReg.manifestOf(address(rwa)); ManifestCore memory expected = _baseManifest(); - bool profileOk = stored.fundRecipeId == expected.fundRecipeId && stored.factsPacked == expected.factsPacked - && stored.fullManifestHash == expected.fullManifestHash; + RecipeBinding[] memory storedBindings = policyReg.recipeBindingsOf(address(rwa)); + bool profileOk = keccak256(abi.encode(storedBindings)) == keccak256(abi.encode(bindings)) + && stored.factsPacked == expected.factsPacked && stored.fullManifestHash == expected.fullManifestHash; bool ok = stored.status == PolicyStatus.ACTIVE && stored.declaredBy == address(factory) && stored.approvedBy == address(factory) && profileOk; console2.log(" evidence: ACTIVE selected asset profile, approved by factory"); @@ -179,7 +186,7 @@ contract DemoScenarios is Script, DemoConstants { // Scenario 4 — Lifecycle (suspend blocks, resume settles) // --------------------------------------------------------------------- function _scenario4_lifecycle() internal { - _title(4, "Lifecycle: suspendManifest blocks the trade; resumeManifest settles it again"); + _title(4, "Lifecycle: suspension blocks trading and governance schedules delayed recovery"); vm.broadcast(deployerPk); policyReg.suspendManifest(address(rwa), bytes32("DEMO-SUSPEND")); @@ -193,16 +200,12 @@ contract DemoScenarios is Script, DemoConstants { 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); + factory.scheduleManifestResume(address(rwa), bytes32("DEMO-RECOVERED")); + (uint64 effectiveTime,) = policyReg.pendingManifestResumeOf(address(rwa)); + bool recoveryScheduled = effectiveTime == block.timestamp + policyReg.MIN_MANIFEST_DELAY(); + console2.log(" evidence: governance scheduled delayed recovery ->", recoveryScheduled); - _record(4, blockedOk && delta == AMM_TRADE); + _record(4, blockedOk && recoveryScheduled); } // --------------------------------------------------------------------- @@ -246,8 +249,7 @@ contract DemoScenarios is Script, DemoConstants { policyReg.retireManifest(address(rwa), bytes32("ADD-SURVEILLANCE")); ManifestCore memory m = _baseManifest(); - m.issuanceRecipeId = SURVEIL_RECIPE_ID; - m.issuanceRecipeVersion = 1; + RecipeBinding[] memory bindings = _surveillanceBindings(); VenueConfig memory ammCfg = VenueConfig({ venueType: VenueType.AMM, adapter: address(ammAdapter), @@ -257,7 +259,7 @@ contract DemoScenarios is Script, DemoConstants { active: true }); vm.broadcast(deployerPk); - factory.registerRWAToken(address(rwa), m, pool, ammCfg); + factory.registerRWAToken(address(rwa), m, bindings, pool, ammCfg); uint256 threshold = 2; vm.broadcast(deployerPk); @@ -475,8 +477,27 @@ contract DemoScenarios is Script, DemoConstants { function _baseManifest() internal view returns (ManifestCore memory m) { if (useBuidlLikeProfile) return BuidlLikeDemoAsset.manifest(ENGINES_AMM | ENGINES_RFQ); - m.issuanceRecipeId = 1; - m.issuanceRecipeVersion = 1; m.supportedEngines = ENGINES_AMM | ENGINES_RFQ; } + + function _baseBindings() internal view returns (RecipeBinding[] memory bindings) { + if (useBuidlLikeProfile) return BuidlLikeDemoAsset.recipeBindings(); + bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(1, 2, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + } + + function _surveillanceBindings() internal view returns (RecipeBinding[] memory bindings) { + uint256 count = useBuidlLikeProfile ? 2 : 1; + bindings = new RecipeBinding[](count); + bindings[0] = RecipeBinding(SURVEIL_RECIPE_ID, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + if (useBuidlLikeProfile) { + bindings[1] = RecipeBinding( + BuidlLikeDemoAsset.FUND_RECIPE_ID, + BuidlLikeDemoAsset.FUND_RECIPE_VERSION, + RecipeBindingMode.REQUIRED_BLOCKING, + 0, + 90 + ); + } + } } diff --git a/script/DeployStack.s.sol b/script/DeployStack.s.sol index 947e55d..6f61bfc 100644 --- a/script/DeployStack.s.sol +++ b/script/DeployStack.s.sol @@ -11,6 +11,7 @@ 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 {AttestedAcquisitionSource} from "../src/registry/AttestedAcquisitionSource.sol"; import {ComplianceEngine} from "../src/compliance/ComplianceEngine.sol"; import {Sanctions} from "../src/compliance/elements/Sanctions.sol"; @@ -83,7 +84,7 @@ contract DeployStack is Script, TREXCore, DemoConstants { Erc3643Native internal erc3643; FormDFiling internal formD; Lockup internal lockup; - DemoAcquisitionSource internal acqSource; + AttestedAcquisitionSource internal acqSource; QualifiedPurchaser internal qualifiedPurchaser; ExecutionRouter internal router; @@ -228,7 +229,7 @@ contract DeployStack is Script, TREXCore, DemoConstants { elementReg.registerElement(bytes32("B-01-v1"), address(assetClass)); erc3643 = new Erc3643Native(); elementReg.registerElement(bytes32("B-02-v1"), address(erc3643)); - acqSource = new DemoAcquisitionSource(); + acqSource = new AttestedAcquisitionSource(); lockup = new Lockup(address(acqSource), LOCKUP_SECONDS); elementReg.registerElement(bytes32("C-01-v1"), address(lockup)); formD = new FormDFiling(); @@ -249,7 +250,14 @@ contract DeployStack is Script, TREXCore, DemoConstants { 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 if (useBuidlLikeProfile) qualifiedPurchaser.setQp(who, true); // A-13 - acqSource.setAcquiredAt(who, address(rwaToken), uint64(1)); // C-01 seed + acqSource.setSnapshot( + who, + address(rwaToken), + uint64(1), + uint64(block.timestamp + 30 days), + keccak256("demo-ta-fixture"), + IAcquisitionSource.AcquisitionStatus.VALID + ); // C-01 seed } function _writeArtifact(address deployer, address investor, address maker, address unapprovedMaker) internal { @@ -300,20 +308,6 @@ contract DeployStack is Script, TREXCore, DemoConstants { } } -/// @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 diff --git a/scripts/check.sh b/scripts/check.sh index e97bd17..2838f32 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,6 +5,15 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT_DIR" +V3_FACTORY_ARTIFACT="tools/deploy-v3/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json" +if [ ! -f "$V3_FACTORY_ARTIFACT" ]; then + echo "==> Installing pinned deploy-v3 dependencies required by canonical pool E2E" + ( + cd tools/deploy-v3 + yarn install --frozen-lockfile + ) +fi + echo "==> Checking Solidity formatting" forge fmt --check @@ -62,6 +71,15 @@ echo "==> Running read-only Operator API smoke test" npm test ) +echo "==> Running compliance data SDK smoke test" +( + cd services/compliance-data + if [ ! -x node_modules/.bin/tsc ]; then + npm ci + fi + npm test +) + echo "==> Running read-only Operator dashboard smoke test" ( cd services/operator-dashboard diff --git a/scripts/e2e-anvil.sh b/scripts/e2e-anvil.sh index 5e33710..ce79d04 100755 --- a/scripts/e2e-anvil.sh +++ b/scripts/e2e-anvil.sh @@ -7,11 +7,13 @@ # and tears the node down on exit. Runs fully offline. # # Usage: -# scripts/e2e-anvil.sh [--port N] [--backend-port N] [--profile buidl-like|reg-d] [--keep] +# scripts/e2e-anvil.sh [--port N] [--backend-port N] [--profile buidl-like|reg-d] [--mode full|rfq] [--keep] # # --port N Anvil port (default 8545). # --backend-port N RFQ demo backend port (default 8787). # --profile Asset profile (default buidl-like; alternative reg-d). +# --mode full runs the 7-scenario suite plus RFQ; rfq runs the concise +# backend/CLI/Router RFQ walkthrough only (default full). # --keep Leave Anvil running after the suite (attach a UI / continue the # demo interactively). Otherwise Anvil is killed on exit. # @@ -25,6 +27,7 @@ PORT=8545 KEEP=0 ASSET_PROFILE=buidl-like BACKEND_PORT=8787 +DEMO_MODE=full while [ $# -gt 0 ]; do case "$1" in --port) PORT="$2"; shift 2 ;; @@ -33,6 +36,8 @@ while [ $# -gt 0 ]; do --profile=*) ASSET_PROFILE="${1#*=}"; shift ;; --backend-port) BACKEND_PORT="$2"; shift 2 ;; --backend-port=*) BACKEND_PORT="${1#*=}"; shift ;; + --mode) DEMO_MODE="$2"; shift 2 ;; + --mode=*) DEMO_MODE="${1#*=}"; shift ;; --keep) KEEP=1; shift ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; @@ -44,6 +49,11 @@ case "$ASSET_PROFILE" in *) echo "invalid --profile: $ASSET_PROFILE (expected buidl-like or reg-d)" >&2; exit 2 ;; esac +case "$DEMO_MODE" in + full|rfq) ;; + *) echo "invalid --mode: $DEMO_MODE (expected full or rfq)" >&2; exit 2 ;; +esac + RPC="http://127.0.0.1:${PORT}" ANVIL_LOG=$(mktemp -t corner-store-anvil.XXXXXX) ANVIL_PID="" @@ -106,10 +116,14 @@ echo "==> Deploying the full stack (script/DeployStack.s.sol)" ASSET_PROFILE="$ASSET_PROFILE" 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 +if [ "$DEMO_MODE" = "full" ]; then + echo "" + echo "==> Running the full 7-scenario demo suite (script/DemoScenarios.s.sol)" + forge script script/DemoScenarios.s.sol:DemoScenarios \ + --rpc-url "$RPC" --broadcast --offline +else + echo "==> RFQ mode: skipping AMM/lifecycle/surveillance scenarios" +fi echo "" echo "==> Building CLI and RFQ demo backend" @@ -117,6 +131,15 @@ npm run build --prefix services/cli >/dev/null npm run build --prefix services/rfq-demo-backend >/dev/null CLI=(node services/cli/dist/cli/src/index.js --rpc "$RPC" --artifact deployments/anvil-e2e.json) +if [ "$DEMO_MODE" = "full" ]; then + echo "==> Advancing the live chain through the manifest recovery timelock" + cast rpc --rpc-url "$RPC" evm_increaseTime 86400 >/dev/null + cast rpc --rpc-url "$RPC" evm_mine >/dev/null + "${CLI[@]}" manifest resume + "${CLI[@]}" buy 5000000 --venue amm + echo " PASS: delayed manifest recovery restored AMM settlement" +fi + echo "==> Running Toolkit artifact preflight and immutable checkpoint" if [ "$ASSET_PROFILE" = "reg-d" ]; then TOOLKIT_CONFIG=services/toolkit/examples/corner-store.reg-d.config.json @@ -131,8 +154,8 @@ echo "==> Re-onboarding the selected asset profile through the CLI" "${CLI[@]}" onboard --profile "$ASSET_PROFILE" echo "==> Starting RFQ demo backend on http://127.0.0.1:${BACKEND_PORT}" -node services/rfq-demo-backend/dist/rfq-demo-backend/src/index.js \ - --port "$BACKEND_PORT" >"$BACKEND_LOG" 2>&1 & +RFQ_DEMO_ENABLE_SETTLEMENT=1 node services/rfq-demo-backend/dist/rfq-demo-backend/src/index.js \ + --port "$BACKEND_PORT" --rpc "$RPC" >"$BACKEND_LOG" 2>&1 & BACKEND_PID=$! BACKEND_READY=0 @@ -152,6 +175,16 @@ if [ "$BACKEND_READY" -ne 1 ]; then exit 1 fi +echo "==> Proving the dashboard's click-through settlement endpoint" +DEMO_SETTLED=$(curl -fsS -X POST "http://127.0.0.1:${BACKEND_PORT}/demo/trade" \ + -H "content-type: application/json" \ + -d '{"amountIn":"5000000000000000000000000","action":"settle"}') +node -e 'const value=JSON.parse(process.argv[1]); if (!value.transaction?.hash || BigInt(value.transaction.rwaDelta) <= 0n) process.exit(1); console.log(` PASS: dashboard trade settled in block ${value.transaction.blockNumber}`);' "$DEMO_SETTLED" +DEMO_REJECTED=$(curl -fsS -X POST "http://127.0.0.1:${BACKEND_PORT}/demo/trade" \ + -H "content-type: application/json" \ + -d '{"amountIn":"5000000000000000000000000","action":"revoked-maker"}') +node -e 'const value=JSON.parse(process.argv[1]); if (!value.rejection) process.exit(1); console.log(" PASS: dashboard maker-revocation was rejected");' "$DEMO_REJECTED" + echo "==> Requesting and filling a backend-signed RFQ quote through the Router" "${CLI[@]}" rfq-quote --backend "http://127.0.0.1:${BACKEND_PORT}" \ --amount-in 5000000 --out "$QUOTE_FILE" @@ -167,5 +200,15 @@ if "${CLI[@]}" buy 0 --venue rfq --quote "$REJECTED_QUOTE_FILE"; then fi echo " PASS: revoked maker quote was rejected" +if [ "$KEEP" -eq 1 ]; then + echo "==> Restoring the demo maker for the interactive session" + "${CLI[@]}" maker approve 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC + echo " maker restored: request another RFQ quote in a second terminal" +fi + echo "" -echo "==> E2E demo complete: scenario suite + backend/CLI/Router RFQ flow passed." +if [ "$DEMO_MODE" = "full" ]; then + echo "==> E2E demo complete: scenario suite + backend/CLI/Router RFQ flow passed." +else + echo "==> RFQ demo complete: mock TA profile + toolkit/CLI + backend/Router RFQ flow passed." +fi diff --git a/services/cli/README.md b/services/cli/README.md index 2881b81..cb824c0 100644 --- a/services/cli/README.md +++ b/services/cli/README.md @@ -91,7 +91,7 @@ CLI v2 (CLI-002) adds preflight, the sell direction, and observability: corner-store check [--venue amm|rfq] [--amount ] [--json] # per-element compliance preflight (no trade) + engine verdict; exit 1 if rejected corner-store sell [--min ] # AMM sell (tokenIn=RWA, tokenOut=QUOTE); defaults to investor account 1 corner-store balances [addr...] [--json] # RWA/QUOTE balances + adapter allowances (default: the 5 well-known roles) -corner-store watch [--from ] # live event tail (Executed/RFQFilled/RFQQuoteCancelled/MakerApprovalSet/Manifest*/SurveillanceFlag); Ctrl-C to stop +corner-store watch [--from ] # live event tail (Executed/RFQFilled/RFQQuoteCancelled/MakerApprovalSet/Manifest*/ComplianceFlags/SurveillanceFlag); Ctrl-C to stop corner-store faucet # mint QUOTE (MockERC20.mint is permissionless — demo-only) corner-store snapshot # anvil evm_snapshot -> prints id corner-store restore # anvil evm_revert (invalidates later snapshots) @@ -142,7 +142,9 @@ $CS attest jurisdiction $ACCT4 US # restore # manifest lifecycle $CS manifest suspend --reason DEMO-SUSPEND $CS --account 4 buy 5000000 # FAIL: POLICY / SUSPENDED -$CS manifest resume +$CS manifest resume # first call schedules timelocked resume +# after the reported delay: +$CS manifest resume # second call executes resume $CS --account 4 buy 5000000 # PASS again # RFQ venue diff --git a/services/cli/src/abi.ts b/services/cli/src/abi.ts index b0c7734..53367ae 100644 --- a/services/cli/src/abi.ts +++ b/services/cli/src/abi.ts @@ -10,6 +10,9 @@ export const ROUTER_ABI = [ // ExecutionRouter (and adapters) surface these custom errors on the revert path. export const ERRORS_ABI = [ "error ComplianceRejected(bytes32 reasonCode)", + "error GlobalPaused()", + "error TokenInPaused()", + "error TokenOutPaused()", "error DeadlineExpired()", "error NotAuthorized()", "error NonceUsed()", @@ -23,7 +26,12 @@ export const ERRORS_ABI = [ "error RFQQuoteUsed()", "error RFQQuoteExpired()", "error RFQInvalidSignature()", - "error RFQQuoteMismatch()" + "error RFQQuoteMismatch()", + "error InvalidRecipeBinding()", + "error TooManyRecipeBindings(uint256 supplied, uint256 maximum)", + "error TooManyRecipeElements(uint16 recipeId, uint256 supplied, uint256 maximum)", + "error DuplicateRecipeBinding(uint16 recipeId)", + "error RecipeVersionMismatch(uint16 recipeId, uint16 expected, uint16 actual)" ]; export const ELEMENT_REGISTRY_ABI = [ @@ -37,13 +45,15 @@ export const RECIPE_REGISTRY_ABI = [ "function recipeOf(uint16 recipeId) view returns (address)" ]; export const RECIPE_ABI = [ + "function version() view returns (uint16)", + "function isApplicable(bytes context) view returns (bool)", "function requiredElements() view returns (bytes32[])" ]; // ComplianceEngine.evaluate(ctx) is a VIEW returning the full ComplianceDecision // (src/types/ComplianceTypes.sol). `check` calls it for the overall verdict. export const ENGINE_ABI = [ - "function evaluate(tuple(address initiator,address buyer,address seller,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint8 venueType,address venue,uint8 flowType,bool sellerIsAffiliate) ctx) view returns (tuple(bool allowed,bytes32 policyId,uint64 policyVersion,uint64 validUntil,uint256 maxAmount,uint256 allowedVenueTypes,bytes32 allowedVenuesHash,bytes32 reasonCode,bytes32 reliedClaims,bytes32 decisionHash))" + "function evaluate(tuple(address initiator,address buyer,address seller,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint8 venueType,address venue,uint8 flowType,bool sellerIsAffiliate) ctx) view returns (tuple(bool allowed,bytes32 policyId,uint64 policyVersion,uint64 validUntil,uint256 maxAmount,uint256 allowedVenueTypes,bytes32 allowedVenuesHash,bytes32 reasonCode,bytes32 reliedClaims,uint256 flagsBitmap,bytes32 decisionHash))" ]; // Event fragments for `watch` (src/libraries/Events.sol + RFQAdapter.sol). Only @@ -53,8 +63,9 @@ export const EVENTS_ABI = [ "event RFQFilled(bytes32 indexed quoteHash, address indexed maker, address indexed taker, uint256 amountIn, uint256 amountOut)", "event RFQQuoteCancelled(address indexed maker, uint256 indexed nonce)", "event MakerApprovalSet(address indexed maker, bool approved)", - "event ManifestRegistered(address indexed token, uint16 issuanceRecipeId, address declaredBy)", + "event ManifestRegistered(address indexed token, bytes32 bindingsHash, address declaredBy)", "event ManifestStatusChanged(address indexed token, uint8 status, bytes32 reasonCode)", + "event ComplianceFlags(bytes32 indexed decisionHash, uint256 flagsBitmap)", "event SurveillanceFlag(bytes32 indexed elementId, address indexed subject, bytes32 reasonCode)" ]; @@ -81,13 +92,21 @@ export const ELEMENT_SETTERS_ABI = [ export const TOKEN_POLICY_REGISTRY_ABI = [ "function statusOf(address token) view returns (uint8)", "function manifestOf(address token) view returns (tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy))", + "function recipeBindingsOf(address token) view returns (tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[])", "function suspendManifest(address token, bytes32 reasonCode)", + "function scheduleManifestResume(address token, bytes32 reasonCode)", + "function pendingManifestResumeOf(address token) view returns (uint64 effectiveTime,bytes32 reasonCode)", + "function MIN_MANIFEST_DELAY() view returns (uint64)", "function resumeManifest(address token)", "function retireManifest(address token, bytes32 reasonCode)" ]; export const FACTORY_ABI = [ - "function registerRWAToken(address token, tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy) manifest, address venue, tuple(uint8 venueType,address adapter,address target,address operator,uint8 custody,bool active) venueCfg)", + "function registerRWAToken(address token, tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy) manifest, tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[] bindings, address venue, tuple(uint8 venueType,address adapter,address target,address operator,uint8 custody,bool active) venueCfg)", + "function scheduleManifestResume(address token, bytes32 reasonCode)", + "function cancelManifestResume(address token)", + "function scheduleManifestUpdate(address token, tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy) manifest, tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[] bindings, bytes32 reasonCode)", + "function cancelManifestUpdate(address token)", "event RWATokenRegistered(address indexed token, address indexed venue)" ]; @@ -102,10 +121,11 @@ export const RFQ_ADAPTER_ABI = [ "function cancelQuoteNonce(uint256 nonce)" ]; -// Lockup (C-01) element exposes its injected acquisition-time source; the source -// itself carries the operator-free demo setter used to seed a holder's clock. +// Lockup (C-01) consumes a provider-neutral operator-attested snapshot. export const LOCKUP_ABI = ["function acquisitionSource() view returns (address)"]; -export const ACQ_SOURCE_ABI = ["function setAcquiredAt(address holder, address asset, uint64 ts)"]; +export const ACQ_SOURCE_ABI = [ + "function setSnapshot(address holder,address asset,uint64 clockStart,uint64 expiresAt,bytes32 sourceRef,uint8 status)" +]; export const ERC20_ABI = [ "function balanceOf(address account) view returns (uint256)", diff --git a/services/cli/src/assetProfiles.ts b/services/cli/src/assetProfiles.ts index 3934e4c..70501a0 100644 --- a/services/cli/src/assetProfiles.ts +++ b/services/cli/src/assetProfiles.ts @@ -4,9 +4,11 @@ import {CliError} from "./util"; export type AssetProfile = "buidl-like" | "reg-d"; +export type RecipeBindingTuple = [recipeId: number, recipeVersion: number, mode: number, pathGroupId: number, priority: number]; + export interface AssetProfileBinding { profile: AssetProfile; - fundRecipeId: number; + bindings: RecipeBindingTuple[]; factsPacked: bigint; fullManifestHash: string; } @@ -47,10 +49,13 @@ export function assetProfileBinding(value?: string): AssetProfileBinding { if (profile === "buidl-like") { return { profile, - fundRecipeId: 3, + bindings: [ + [1, 2, 0, 0, 100], + [3, 1, 0, 0, 90] + ], factsPacked: 1n, fullManifestHash: BUIDL_LIKE_MANIFEST_HASH }; } - return {profile, fundRecipeId: 0, factsPacked: 0n, fullManifestHash: ZERO32}; + return {profile, bindings: [[1, 2, 0, 0, 100]], factsPacked: 0n, fullManifestHash: ZERO32}; } diff --git a/services/cli/src/commands.ts b/services/cli/src/commands.ts index 98116d8..d012b33 100644 --- a/services/cli/src/commands.ts +++ b/services/cli/src/commands.ts @@ -55,9 +55,62 @@ const CTX_TUPLE = "tuple(address initiator,address buyer,address seller,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint8 venueType,address venue,uint8 flowType,bool sellerIsAffiliate)"; const VENUE_TYPE_NAMES = ["AMM", "ORDER_BOOK", "RFQ"]; +const RECIPE_BINDING_MODE_NAMES = ["REQUIRED_BLOCKING", "PATH_OPTION", "FLAG_ONLY"]; const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; const ZERO32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; +function bindingRecipeId(binding: any): number { + return Number(binding.recipeId ?? binding[0]); +} + +function bindingRecipeVersion(binding: any): number { + return Number(binding.recipeVersion ?? binding[1]); +} + +function bindingMode(binding: any): number { + return Number(binding.mode ?? binding[2]); +} + +function bindingPathGroupId(binding: any): number { + return Number(binding.pathGroupId ?? binding[3]); +} + +function bindingPriority(binding: any): number { + return Number(binding.priority ?? binding[4]); +} + +function bindingRecipeIds(bindings: any[]): number[] { + const ids: number[] = []; + for (const binding of bindings) { + const rid = bindingRecipeId(binding); + if (rid !== 0 && !ids.includes(rid)) ids.push(rid); + } + return ids; +} + +function bindingSummary(binding: any): string { + const rid = bindingRecipeId(binding); + const version = bindingRecipeVersion(binding); + const mode = bindingMode(binding); + const group = bindingPathGroupId(binding); + const priority = bindingPriority(binding); + return `${rid}v${version} (${RECIPE_LABELS[rid] ?? "?"}) mode=${RECIPE_BINDING_MODE_NAMES[mode] ?? mode} group=${group} priority=${priority}`; +} + +function bindingJson(binding: any): Record { + const rid = bindingRecipeId(binding); + const mode = bindingMode(binding); + return { + recipeId: rid, + recipeVersion: bindingRecipeVersion(binding), + recipeName: RECIPE_LABELS[rid] ?? "?", + mode, + modeName: RECIPE_BINDING_MODE_NAMES[mode] ?? "?", + pathGroupId: bindingPathGroupId(binding), + priority: bindingPriority(binding) + }; +} + export function cmdToolkitInit(path = "corner-store.config.json"): void { const target = resolve(process.cwd(), path); try { @@ -192,6 +245,7 @@ export async function cmdStatus(positional: string | undefined, opts: GlobalOpts const policy = policyRegistry(a, provider); const manifest = await policy.manifestOf(a.rwaToken); + const bindings: any[] = await policy.recipeBindingsOf(a.rwaToken); const status = Number(manifest.status); const supportedEngines = Number(manifest.supportedEngines); @@ -209,8 +263,25 @@ export async function cmdStatus(positional: string | undefined, opts: GlobalOpts const ctx = [subject, subject, a.pool, a.quote, a.rwaToken, parseEther("1"), parseEther("1"), 0, a.pool, 0, false]; const coder = require("ethers").AbiCoder.defaultAbiCoder(); const elementContext = coder.encode([CTX_TUPLE], [ctx]); + const recipeContext = coder.encode(["uint256", CTX_TUPLE], [manifest.factsPacked, ctx]); + const recipeIds = bindingRecipeIds(bindings); + const recipeReg = recipeRegistry(a, provider); + const activeElementIds: string[] = []; + for (const rid of recipeIds) { + const recipeAddr = await recipeReg.recipeOf(rid); + if (recipeAddr === ZERO_ADDR) continue; + const recipe = new Contract(recipeAddr, RECIPE_ABI, provider); + if (!(await recipe.isApplicable(recipeContext))) continue; + const requiredIds: string[] = await recipe.requiredElements(); + for (const raw of requiredIds) { + const id = decodeBytes32String(raw); + if (!activeElementIds.includes(id)) activeElementIds.push(id); + } + } + const elements: Array<{id: string; label: string; passed: boolean}> = []; - for (const [id, label] of Object.entries(ELEMENT_LABELS)) { + for (const id of activeElementIds) { + const label = ELEMENT_LABELS[id] ?? "?"; const elAddr = await reg.elementOf(encodeBytes32String(id)); if (elAddr === "0x0000000000000000000000000000000000000000") { elements.push({id, label, passed: false}); @@ -236,8 +307,7 @@ export async function cmdStatus(positional: string | undefined, opts: GlobalOpts manifest: { status, statusName: POLICY_STATUS[status] ?? "?", - issuanceRecipeId: Number(manifest.issuanceRecipeId), - recipeName: RECIPE_LABELS[Number(manifest.issuanceRecipeId)] ?? "?", + bindings: bindings.map(bindingJson), supportedEngines, declaredBy: manifest.declaredBy, approvedBy: manifest.approvedBy @@ -261,9 +331,8 @@ export async function cmdStatus(positional: string | undefined, opts: GlobalOpts console.log(""); console.log("RWA manifest:"); console.log(` status ${status} (${POLICY_STATUS[status] ?? "?"})`); - console.log( - ` issuanceRecipe ${Number(manifest.issuanceRecipeId)} (${RECIPE_LABELS[Number(manifest.issuanceRecipeId)] ?? "?"})` - ); + console.log(" recipeBindings"); + for (const binding of bindings) console.log(` - ${bindingSummary(binding)}`); console.log(` supportedEngines 0b${supportedEngines.toString(2).padStart(3, "0")} (AMM=${!!(supportedEngines & 1)}, RFQ=${!!(supportedEngines & 4)})`); console.log(` declaredBy ${manifest.declaredBy}`); console.log(` approvedBy ${manifest.approvedBy}`); @@ -313,9 +382,9 @@ export async function cmdOnboard(opts: GlobalOpts & {engines?: string; profile?: const m = [ 2, - 1, - 1, - binding.fundRecipeId, + 0, + 0, + 0, 0, mask, 0, @@ -326,7 +395,7 @@ export async function cmdOnboard(opts: GlobalOpts & {engines?: string; profile?: ZERO_ADDR ]; const venueCfg = [0, a.ammAdapter, a.pool, ZERO_ADDR, 1, true]; // AMM, custody POOL - await logTx(await factory(a, signer).registerRWAToken(a.rwaToken, m, a.pool, venueCfg), "registerRWAToken"); + await logTx(await factory(a, signer).registerRWAToken(a.rwaToken, m, binding.bindings, a.pool, venueCfg), "registerRWAToken"); console.log(`Onboarded ${binding.profile} RWA ${a.rwaToken} with supportedEngines 0b${mask.toString(2).padStart(3, "0")} + AMM venue ${a.pool}`); } @@ -351,7 +420,21 @@ export async function cmdManifest(action: string, opts: GlobalOpts & {reason?: s await logTx(await policy.suspendManifest(a.rwaToken, reason), "suspendManifest"); break; case "resume": - await logTx(await policy.resumeManifest(a.rwaToken), "resumeManifest"); + { + const pending = await policy.pendingManifestResumeOf(a.rwaToken); + const effectiveTime = Number(pending.effectiveTime); + if (effectiveTime === 0) { + await logTx(await factory(a, signer).scheduleManifestResume(a.rwaToken, reason), "scheduleManifestResume"); + const delay = Number(await policy.MIN_MANIFEST_DELAY()); + console.log(` resume scheduled; run the same command again after ${delay}s timelock`); + return; + } + const latest = await provider.getBlock("latest"); + if (!latest || latest.timestamp < effectiveTime) { + throw new CliError(`manifest resume timelock not ready; effective at unix ${effectiveTime}`); + } + await logTx(await policy.resumeManifest(a.rwaToken), "resumeManifest"); + } break; case "retire": await logTx(await policy.retireManifest(a.rwaToken, reason), "retireManifest"); @@ -415,12 +498,17 @@ export async function cmdInvestorSetup(subject: string, opts: GlobalOpts & {fund const san = await elementContract(a, "A-01-v1", signer, reg); await logTx(await san.setBlocked(subject, false), `sanctions.setBlocked(${subject}, false)`); - // C-01 Rule 144 lockup: seed the acquisition-time source at t=1 so the (already - // elapsed) lockup window passes, mirroring DeployStack's investor seed. + // C-01 Rule 144 lockup: seed a PII-free, expiring mock TA snapshot at t=1 so + // the elapsed demo lockup passes. Production uses a verified provider adapter. const lockupAddr = await elementRegistry(a, reg).elementOf(encodeBytes32String("C-01-v1")); const acqAddr = await new Contract(lockupAddr, LOCKUP_ABI, reg).acquisitionSource(); const acq = new Contract(acqAddr, ACQ_SOURCE_ABI, signer); - await logTx(await acq.setAcquiredAt(subject, a.rwaToken, 1), `lockup.acquisitionSource.setAcquiredAt(${subject}, rwa, 1)`); + const latest = await provider.getBlock("latest"); + if (!latest) throw new CliError("cannot read latest block for acquisition snapshot expiry"); + await logTx( + await acq.setSnapshot(subject, a.rwaToken, 1, latest.timestamp + 30 * 24 * 60 * 60, encodeBytes32String("CLI-DEMO-TA"), 1), + `lockup.acquisitionSource.setSnapshot(${subject}, rwa)` + ); // fund the buyer with QUOTE so it can trade (MockERC20.mint is permissionless). const fund = parseEther(opts.fund ?? (profile === "buidl-like" ? "20000000" : "5000")); @@ -507,7 +595,9 @@ export async function cmdBuy( await logTx(await quoteToken.approve(adapterForApproval, (1n << 256n) - 1n), "approve"); } - const req = [ctx, amountOutMin, BigInt(Math.floor(Date.now() / 1000) + 3600), nonce, venueData]; + const latest = await provider.getBlock("latest"); + if (!latest) throw new CliError("cannot read latest block for execution deadline"); + const req = [ctx, amountOutMin, BigInt(latest.timestamp + 3600), nonce, venueData]; console.log(`Executing ${venue.toUpperCase()} buy: amountIn=${formatEther(amountIn)} as ${buyer}`); await logTx(await router(a, signer).execute(req), "execute"); @@ -548,7 +638,16 @@ export async function cmdRfqQuote(opts: GlobalOpts & { const maker = walletForAccount(Number(opts.makerAccount)).connect(provider); const service = new RFQQuoteService( - {chainId: DEFAULT_CHAIN_ID, verifyingContract: a.rfqAdapter as `0x${string}`, defaultTtlSeconds: ttl}, + { + chainId: DEFAULT_CHAIN_ID, + verifyingContract: a.rfqAdapter as `0x${string}`, + defaultTtlSeconds: ttl, + now: async () => { + const latest = await provider.getBlock("latest"); + if (!latest) throw new CliError("cannot read latest block for RFQ expiry"); + return latest.timestamp; + } + }, new WalletTypedDataSigner(maker) ); signed = await service.createSignedQuote({ @@ -659,12 +758,14 @@ export async function cmdCheck( const ctx = [buyer, buyer, seller, a.quote, a.rwaToken, amount, amount, venueType, venueAddr, 0, false]; // Active manifest's recipe ids -> requiredElements -> element addresses. - const manifest = await policyRegistry(a, provider).manifestOf(a.rwaToken); + const policy = policyRegistry(a, provider); + const manifest = await policy.manifestOf(a.rwaToken); + const bindings: any[] = await policy.recipeBindingsOf(a.rwaToken); const status = Number(manifest.status); const recipeIds: number[] = []; - for (const rid of [Number(manifest.issuanceRecipeId), Number(manifest.fundRecipeId)]) { - if (rid !== 0 && !recipeIds.includes(rid)) recipeIds.push(rid); - } + const coder = require("ethers").AbiCoder.defaultAbiCoder(); + const elementContext = coder.encode([CTX_TUPLE], [ctx]); + const recipeContext = coder.encode(["uint256", CTX_TUPLE], [manifest.factsPacked, ctx]); const reg = elementRegistry(a, provider); const recipeReg = recipeRegistry(a, provider); @@ -677,10 +778,18 @@ export async function cmdCheck( passed: boolean; reason?: string; }> = []; - for (const rid of recipeIds) { + for (const binding of bindings) { + const rid = bindingRecipeId(binding); const recipeAddr = await recipeReg.recipeOf(rid); if (recipeAddr === ZERO_ADDR) throw new CliError(`recipe ${rid} not registered in RecipeRegistry`); - const requiredIds: string[] = await new Contract(recipeAddr, RECIPE_ABI, provider).requiredElements(); + const recipe = new Contract(recipeAddr, RECIPE_ABI, provider); + const actualVersion = Number(await recipe.version()); + if (actualVersion !== bindingRecipeVersion(binding)) { + throw new CliError(`recipe ${rid} version mismatch: binding=${bindingRecipeVersion(binding)}, registry=${actualVersion}`); + } + if (!(await recipe.isApplicable(recipeContext))) continue; + recipeIds.push(rid); + const requiredIds: string[] = await recipe.requiredElements(); for (const raw of requiredIds) { const idStr = decodeBytes32String(raw); if (seen.has(idStr)) continue; @@ -693,7 +802,13 @@ export async function cmdCheck( continue; } try { - const [passed] = await new Contract(elAddr, ELEMENT_ABI, provider).check(buyer, seller, a.rwaToken, amount, "0x"); + const [passed] = await new Contract(elAddr, ELEMENT_ABI, provider).check( + buyer, + seller, + a.rwaToken, + amount, + elementContext + ); // The recipe-aware reason the engine would report for THIS element. const reason = passed ? undefined : decodeReason(encodeReason(rid, idStr, 1)).label; rows.push({id: idStr, label, assetSide, recipeId: rid, passed, reason}); @@ -724,9 +839,15 @@ export async function cmdCheck( amount: formatEther(amount), seller, manifest: {status, statusName: POLICY_STATUS[status] ?? "?"}, + bindings: bindings.map(bindingJson), recipes: recipeIds.map((r) => ({id: r, name: RECIPE_LABELS[r] ?? "?"})), elements: rows, - verdict: {allowed, reasonCode: String(decision.reasonCode), reason: verdictReason} + verdict: { + allowed, + reasonCode: String(decision.reasonCode), + reason: verdictReason, + flagsBitmap: decision.flagsBitmap.toString() + } }, null, 2 @@ -739,8 +860,8 @@ export async function cmdCheck( console.log(`Preflight for ${buyer}`); console.log(` venue=${venue} amount=${formatEther(amount)} seller/counterparty=${seller}`); console.log( - ` manifest status ${status} (${POLICY_STATUS[status] ?? "?"}); recipes: ${ - recipeIds.map((r) => `${r} (${RECIPE_LABELS[r] ?? "?"})`).join(", ") || "none" + ` manifest status ${status} (${POLICY_STATUS[status] ?? "?"}); bindings: ${ + bindings.map(bindingSummary).join(", ") || "none" }` ); console.log(""); @@ -752,9 +873,9 @@ export async function cmdCheck( } console.log(""); if (allowed) { - console.log("Engine verdict: ALLOWED"); + console.log(`Engine verdict: ALLOWED flagsBitmap=${decision.flagsBitmap.toString()}`); } else { - console.log(`Engine verdict: REJECTED ${String(decision.reasonCode)} -> ${verdictReason}`); + console.log(`Engine verdict: REJECTED ${String(decision.reasonCode)} -> ${verdictReason} flagsBitmap=${decision.flagsBitmap.toString()}`); process.exitCode = 1; } } @@ -790,7 +911,9 @@ export async function cmdSell(amountInArg: string, opts: GlobalOpts & {min?: str const rwaBefore = await erc20(a.rwaToken, provider).balanceOf(seller); const quoteBefore = await erc20(a.quote, provider).balanceOf(seller); - const req = [ctx, amountOutMin, BigInt(Math.floor(Date.now() / 1000) + 3600), nonce, venueData]; + const latest = await provider.getBlock("latest"); + if (!latest) throw new CliError("cannot read latest block for execution deadline"); + const req = [ctx, amountOutMin, BigInt(latest.timestamp + 3600), nonce, venueData]; console.log(`Executing AMM sell: amountIn=${formatEther(amountIn)} RWA as ${seller}`); await logTx(await router(a, signer).execute(req), "execute"); @@ -893,6 +1016,7 @@ const WATCH_EVENTS = [ "MakerApprovalSet", "ManifestRegistered", "ManifestStatusChanged", + "ComplianceFlags", "SurveillanceFlag" ]; @@ -941,11 +1065,13 @@ function formatEvent(parsed: {name: string; args: any}): string { case "MakerApprovalSet": return `MakerApprovalSet maker=${args.maker} approved=${args.approved}`; case "ManifestRegistered": - return `ManifestRegistered token=${args.token} issuanceRecipeId=${args.issuanceRecipeId} declaredBy=${args.declaredBy}`; + return `ManifestRegistered token=${args.token} bindingsHash=${args.bindingsHash} declaredBy=${args.declaredBy}`; case "ManifestStatusChanged": { const s = Number(args.status); return `ManifestStatusChanged token=${args.token} status=${s} (${POLICY_STATUS[s] ?? "?"}) reason=${describeReasonCode(String(args.reasonCode))}`; } + case "ComplianceFlags": + return `ComplianceFlags decisionHash=${args.decisionHash} flagsBitmap=${args.flagsBitmap}`; case "SurveillanceFlag": { const el = bytes32Label(String(args.elementId)); const elLabel = ELEMENT_LABELS[el] ? ` (${ELEMENT_LABELS[el]})` : ""; @@ -1045,7 +1171,9 @@ export async function cmdQuoteInspect(file: string, opts: GlobalOpts & {json?: b recovered = ``; } - const now = Math.floor(Date.now() / 1000); + const latest = await provider.getBlock("latest"); + if (!latest) throw new CliError("cannot read latest block for quote expiry"); + const now = latest.timestamp; const secsLeft = Number(q.expiry) - now; const expired = secsLeft <= 0; diff --git a/services/cli/test/smoke.ts b/services/cli/test/smoke.ts index 608be52..a083fa0 100644 --- a/services/cli/test/smoke.ts +++ b/services/cli/test/smoke.ts @@ -66,7 +66,16 @@ async function main() { "a deployed BUIDL-like asset cannot be rebound to weaker Reg D policy" ); const buidl = assetProfileBinding("buidl-like"); - assert(buidl.fundRecipeId === 3 && buidl.factsPacked === 1n, "BUIDL-like Recipe/facts binding"); + assert( + JSON.stringify(buidl.bindings) === JSON.stringify([[1, 2, 0, 0, 100], [3, 1, 0, 0, 90]]) && + buidl.factsPacked === 1n, + "BUIDL-like RecipeBinding[]/facts binding" + ); + const regD = assetProfileBinding("reg-d"); + assert( + JSON.stringify(regD.bindings) === JSON.stringify([[1, 2, 0, 0, 100]]) && regD.factsPacked === 0n, + "Reg D uses a single RecipeBinding[] without fund mirror behavior" + ); assert( buidl.fullManifestHash === "0xdcf411c4cfd970828531bfbaa85d4e6f833b6fb731a32add099081e4eea5b7c9", "BUIDL-like Manifest hash matches the Solidity profile" diff --git a/services/compliance-data/README.md b/services/compliance-data/README.md new file mode 100644 index 0000000..d81a7c8 --- /dev/null +++ b/services/compliance-data/README.md @@ -0,0 +1,19 @@ +# Corner Store Compliance Data SDK + +Provider-neutral foundation for ADR-008's off-chain compliance data layer. + +It provides: + +- Transfer Agent lot ingestion and deterministic lineage/clock resolution; +- conservative holder×asset snapshots for `AttestedAcquisitionSource`; +- idempotent person-group volume and holder state; +- tamper-evident rejection and out-of-router surveillance records. + +```ts +const snapshot = await new AcquisitionResolver(provider).compile(holder, asset, chainTimestamp); +``` + +The SDK does **not** claim Securitize Connect API compatibility. A production +operator must supply and verify a provider adapter, authorization, PII controls, +durable database/WORM storage, finality/reorg handling, alerting and retention. +Only snapshot hashes and PII-free references should be submitted on-chain. diff --git a/services/compliance-data/package-lock.json b/services/compliance-data/package-lock.json new file mode 100644 index 0000000..a904f48 --- /dev/null +++ b/services/compliance-data/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@corner-store/compliance-data", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@corner-store/compliance-data", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/services/compliance-data/package.json b/services/compliance-data/package.json new file mode 100644 index 0000000..1daf1d3 --- /dev/null +++ b/services/compliance-data/package.json @@ -0,0 +1,16 @@ +{ + "name": "@corner-store/compliance-data", + "version": "0.1.0", + "private": true, + "description": "Provider-neutral Transfer Agent, compliance state, and audit SDK for Corner Store.", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node dist/test/smoke.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3" + } +} diff --git a/services/compliance-data/src/acquisition.ts b/services/compliance-data/src/acquisition.ts new file mode 100644 index 0000000..a81c27c --- /dev/null +++ b/services/compliance-data/src/acquisition.ts @@ -0,0 +1,103 @@ +import {assertAddress, assertTimestamp, assertUintString, hash} from "./canonical"; +import { + AcquisitionLot, + Address, + CompiledAcquisitionSnapshot, + TransferAgentProvider +} from "./types"; + +const LINEAGE_REQUIRED = new Set(["DIVIDEND", "CONVERSION", "PLEDGE", "GIFT", "TRUST", "ESTATE"]); + +export class AcquisitionResolver { + constructor(private readonly provider: TransferAgentProvider, private readonly snapshotTtlSeconds = 86_400) { + if (!Number.isSafeInteger(snapshotTtlSeconds) || snapshotTtlSeconds <= 0) { + throw new Error("snapshotTtlSeconds must be a positive safe integer"); + } + } + + async compile(holder: Address, asset: Address, observedAt: number): Promise { + assertTimestamp(observedAt, "observedAt"); + assertAddress(holder, "holder"); + assertAddress(asset, "asset"); + const lots = await this.provider.lots(holder, asset); + if (lots.length === 0) return this.snapshot(holder, asset, observedAt, 0, "MISSING", []); + + const byId = new Map(); + for (const lot of lots) { + validateLot(lot, holder, asset, observedAt); + if (byId.has(lot.lotId)) throw new Error(`duplicate lotId: ${lot.lotId}`); + byId.set(lot.lotId, lot); + } + + const clocks: number[] = []; + for (const lot of lots) { + const clock = resolveClock(lot, byId, new Set()); + if (clock === undefined) return this.snapshot(holder, asset, observedAt, 0, "LINEAGE_BROKEN", lots); + clocks.push(clock); + } + + // A single holder×asset snapshot cannot safely express lot selection. Use + // the latest current-lot clock so every represented lot must have matured. + return this.snapshot(holder, asset, observedAt, Math.max(...clocks), "VALID", lots); + } + + private snapshot( + holder: Address, + asset: Address, + observedAt: number, + clockStart: number, + status: CompiledAcquisitionSnapshot["status"], + lots: AcquisitionLot[] + ): CompiledAcquisitionSnapshot { + return { + holder, + asset, + clockStart, + observedAt, + expiresAt: safeExpiry(observedAt, this.snapshotTtlSeconds), + sourceRef: hash(lots), + status + }; + } +} + +function validateLot(lot: AcquisitionLot, holder: Address, asset: Address, observedAt: number): void { + if (!lot.lotId.trim()) throw new Error("lotId is required"); + if (lot.holder.toLowerCase() !== holder.toLowerCase() || lot.asset.toLowerCase() !== asset.toLowerCase()) { + throw new Error(`lot ${lot.lotId} does not match requested holder/asset`); + } + assertUintString(lot.quantity, `lot ${lot.lotId} quantity`); + assertTimestamp(lot.acquisitionDate, `lot ${lot.lotId} acquisitionDate`); + assertTimestamp(lot.paymentCompleteAt, `lot ${lot.lotId} paymentCompleteAt`); + if (lot.acquisitionDate > observedAt || lot.paymentCompleteAt > observedAt) { + throw new Error(`lot ${lot.lotId} contains a future acquisition/payment timestamp`); + } + if (!new Set(["PRIMARY", "SECONDARY", "DIVIDEND", "CONVERSION", "PLEDGE", "GIFT", "TRUST", "ESTATE"]).has(lot.sourceType)) { + throw new Error(`lot ${lot.lotId} has unsupported sourceType`); + } + if (lot.lineageRef && !LINEAGE_REQUIRED.has(lot.sourceType)) { + throw new Error(`${lot.sourceType.toLowerCase()} lot ${lot.lotId} cannot inherit lineage`); + } +} + +function safeExpiry(observedAt: number, ttl: number): number { + const expiresAt = observedAt + ttl; + if (!Number.isSafeInteger(expiresAt)) throw new Error("snapshot expiry exceeds safe integer range"); + return expiresAt; +} + +function resolveClock( + lot: AcquisitionLot, + byId: Map, + visiting: Set +): number | undefined { + if (visiting.has(lot.lotId)) return undefined; + const requiresLineage = LINEAGE_REQUIRED.has(lot.sourceType); + if (!lot.lineageRef) return requiresLineage ? undefined : Math.max(lot.acquisitionDate, lot.paymentCompleteAt); + const parent = byId.get(lot.lineageRef); + if (!parent) return undefined; + visiting.add(lot.lotId); + const clock = resolveClock(parent, byId, visiting); + visiting.delete(lot.lotId); + return clock; +} diff --git a/services/compliance-data/src/audit.ts b/services/compliance-data/src/audit.ts new file mode 100644 index 0000000..1855a35 --- /dev/null +++ b/services/compliance-data/src/audit.ts @@ -0,0 +1,79 @@ +import {appendFileSync, existsSync, readFileSync} from "fs"; +import {assertAddress, assertBytes32, assertUintString, canonicalJson, hash} from "./canonical"; +import {AuditEntry, ComplianceAuditRecord, Hex, SurveillanceRecord} from "./types"; + +const ZERO_HASH = `0x${"0".repeat(64)}` as Hex; + +export class HashChainAuditLog { + private readonly entries: AuditEntry[]; + + constructor(private readonly path?: string) { + this.entries = path && existsSync(path) + ? readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as AuditEntry) + : []; + this.assertValid(); + } + + append(record: ComplianceAuditRecord): AuditEntry { + validateRecord(record); + const previousHash = this.entries.at(-1)?.recordHash ?? ZERO_HASH; + const sequence = this.entries.length + 1; + const recordHash = hash({sequence, previousHash, record}); + const entry = {sequence, previousHash, recordHash, record}; + this.entries.push(entry); + if (this.path) appendFileSync(this.path, `${canonicalJson(entry)}\n`, {encoding: "utf8", flag: "a"}); + return entry; + } + + list(): AuditEntry[] { + // Audit records are JSON-safe by construction. Round-tripping keeps callers + // from mutating the in-memory chain without requiring Node's newer + // structuredClone global. + return JSON.parse(JSON.stringify(this.entries)) as AuditEntry[]; + } + + assertValid(): void { + let previousHash = ZERO_HASH; + for (let index = 0; index < this.entries.length; index += 1) { + const entry = this.entries[index]; + const expectedSequence = index + 1; + const expectedHash = hash({sequence: expectedSequence, previousHash, record: entry.record}); + if (entry.sequence !== expectedSequence || entry.previousHash !== previousHash || entry.recordHash !== expectedHash) { + throw new Error(`audit hash chain invalid at sequence ${expectedSequence}`); + } + previousHash = entry.recordHash; + } + } +} + +export class TransferSurveillance { + constructor(private readonly audit: HashChainAuditLog) {} + + observe(record: Omit): AuditEntry | undefined { + if (record.route === "APPROVED_ROUTER") return undefined; + return this.audit.append({ + ...record, + kind: "SURVEILLANCE", + finding: "TRANSFER_OUTSIDE_APPROVED_ROUTER", + riskTier: record.route === "UNKNOWN" ? "HIGH" : "MEDIUM" + }); + } +} + +function validateRecord(record: ComplianceAuditRecord): void { + if (!Number.isSafeInteger(record.timestamp) || record.timestamp <= 0) throw new Error("invalid audit timestamp"); + assertAddress(record.from, "record from"); + assertAddress(record.to, "record to"); + assertUintString(record.amount, "record amount", true); + if (record.kind === "REJECTION") { + if (!record.failedElement) throw new Error("failedElement is required"); + assertAddress(record.tokenIn, "record tokenIn"); + assertAddress(record.tokenOut, "record tokenOut"); + assertBytes32(record.reasonCode, "record reasonCode"); + for (const ref of record.attestedFactRefs) assertBytes32(ref, "attestedFactRef"); + } else { + if (!record.finding) throw new Error("surveillance finding is required"); + assertAddress(record.token, "record token"); + assertBytes32(record.transactionHash, "record transactionHash"); + } +} diff --git a/services/compliance-data/src/canonical.ts b/services/compliance-data/src/canonical.ts new file mode 100644 index 0000000..2d58b4e --- /dev/null +++ b/services/compliance-data/src/canonical.ts @@ -0,0 +1,33 @@ +import {createHash} from "crypto"; +import {Hex} from "./types"; + +export function canonicalJson(value: unknown): string { + if (value === undefined) return "null"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object).filter((key) => object[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`; +} + +export function hash(value: unknown): Hex { + return `0x${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +export function assertTimestamp(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`); +} + +export function assertUintString(value: string, name: string, allowZero = false): bigint { + if (!/^(0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be a canonical unsigned integer string`); + const parsed = BigInt(value); + if (!allowZero && parsed === 0n) throw new Error(`${name} must be positive`); + return parsed; +} + +export function assertAddress(value: string, name: string): void { + if (!/^0x[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${name} must be a 20-byte hex address`); +} + +export function assertBytes32(value: string, name: string): void { + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${name} must be bytes32 hex`); +} diff --git a/services/compliance-data/src/index.ts b/services/compliance-data/src/index.ts new file mode 100644 index 0000000..ee11ec8 --- /dev/null +++ b/services/compliance-data/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./acquisition"; +export * from "./audit"; +export * from "./state"; diff --git a/services/compliance-data/src/state.ts b/services/compliance-data/src/state.ts new file mode 100644 index 0000000..fdbf171 --- /dev/null +++ b/services/compliance-data/src/state.ts @@ -0,0 +1,68 @@ +import {assertTimestamp, assertUintString, hash} from "./canonical"; +import {Hex, HolderCounts, HolderUpdate, VolumeCommit} from "./types"; + +interface VolumePoint { + groupId: string; + timestamp: number; + amount: bigint; +} + +export class PersonGroupLedger { + private readonly commits = new Map(); + private readonly volumes: VolumePoint[] = []; + private readonly holders = new Map(); + + commit(input: VolumeCommit): {applied: boolean; commitHash: Hex} { + validateCommit(input); + const commitHash = hash(input); + const previous = this.commits.get(input.executionId); + if (previous) { + if (previous !== commitHash) throw new Error(`conflicting commit replay: ${input.executionId}`); + return {applied: false, commitHash}; + } + + this.commits.set(input.executionId, commitHash); + this.volumes.push({groupId: input.sellerGroupId, timestamp: input.timestamp, amount: BigInt(input.amount)}); + for (const update of input.holderUpdates ?? []) this.holders.set(update.groupId, {...update}); + return {applied: true, commitHash}; + } + + rollingThreeCalendarMonthVolume(groupId: string, asOf: number): bigint { + assertTimestamp(asOf, "asOf"); + const start = threeCalendarMonthsBefore(asOf); + return this.volumes + .filter((point) => point.groupId === groupId && point.timestamp >= start && point.timestamp <= asOf) + .reduce((sum, point) => sum + point.amount, 0n); + } + + holderCounts(): HolderCounts { + const active = [...this.holders.values()].filter((holder) => holder.isHolder); + return { + total: active.length, + nonAccredited: active.filter((holder) => !holder.isAccredited).length, + usResident: active.filter((holder) => holder.isUsResident).length + }; + } +} + +function validateCommit(input: VolumeCommit): void { + if (!/^0x[0-9a-fA-F]{64}$/.test(input.executionId)) throw new Error("executionId must be bytes32 hex"); + if (!input.sellerGroupId.trim()) throw new Error("sellerGroupId is required"); + assertTimestamp(input.timestamp, "timestamp"); + assertUintString(input.amount, "amount"); + const groups = new Set(); + for (const update of input.holderUpdates ?? []) { + if (!update.groupId.trim() || groups.has(update.groupId)) throw new Error("holderUpdates require unique groupId values"); + groups.add(update.groupId); + } +} + +function threeCalendarMonthsBefore(timestamp: number): number { + const date = new Date(timestamp * 1_000); + const day = date.getUTCDate(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() - 3); + const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate(); + date.setUTCDate(Math.min(day, lastDay)); + return Math.floor(date.getTime() / 1_000); +} diff --git a/services/compliance-data/src/types.ts b/services/compliance-data/src/types.ts new file mode 100644 index 0000000..7f7a5e6 --- /dev/null +++ b/services/compliance-data/src/types.ts @@ -0,0 +1,101 @@ +export type Address = `0x${string}`; +export type Hex = `0x${string}`; + +export type AcquisitionSourceType = + | "PRIMARY" + | "SECONDARY" + | "DIVIDEND" + | "CONVERSION" + | "PLEDGE" + | "GIFT" + | "TRUST" + | "ESTATE"; + +export interface AcquisitionLot { + lotId: string; + holder: Address; + asset: Address; + quantity: string; + acquisitionDate: number; + paymentCompleteAt: number; + sourceType: AcquisitionSourceType; + lineageRef?: string; +} + +export interface TransferAgentProvider { + lots(holder: Address, asset: Address): Promise; +} + +export type AcquisitionSnapshotStatus = "MISSING" | "VALID" | "LINEAGE_BROKEN"; + +export interface CompiledAcquisitionSnapshot { + holder: Address; + asset: Address; + clockStart: number; + observedAt: number; + expiresAt: number; + sourceRef: Hex; + status: AcquisitionSnapshotStatus; +} + +export type RiskTier = "LOW" | "MEDIUM" | "HIGH"; + +export interface RejectionRecord { + kind: "REJECTION"; + timestamp: number; + attemptTxRef: string; + from: Address; + to: Address; + tokenIn: Address; + tokenOut: Address; + amount: string; + direction: "BUY" | "SELL" | "TRANSFER"; + failedElement: string; + reasonCode: Hex; + attestedFactRefs: Hex[]; + reliedExemption: string; + riskTier: RiskTier; +} + +export interface SurveillanceRecord { + kind: "SURVEILLANCE"; + timestamp: number; + transactionHash: Hex; + token: Address; + from: Address; + to: Address; + amount: string; + route: "APPROVED_ROUTER" | "DIRECT_TRANSFER" | "DIRECT_VENUE" | "UNKNOWN"; + finding: string; + riskTier: RiskTier; +} + +export type ComplianceAuditRecord = RejectionRecord | SurveillanceRecord; + +export interface AuditEntry { + sequence: number; + previousHash: Hex; + recordHash: Hex; + record: ComplianceAuditRecord; +} + +export interface VolumeCommit { + executionId: Hex; + sellerGroupId: string; + timestamp: number; + amount: string; + holderUpdates?: HolderUpdate[]; +} + +export interface HolderUpdate { + groupId: string; + isHolder: boolean; + isAccredited: boolean; + isUsResident: boolean; +} + +export interface HolderCounts { + total: number; + nonAccredited: number; + usResident: number; +} diff --git a/services/compliance-data/test/smoke.ts b/services/compliance-data/test/smoke.ts new file mode 100644 index 0000000..fe5daa4 --- /dev/null +++ b/services/compliance-data/test/smoke.ts @@ -0,0 +1,152 @@ +import {mkdtempSync, readFileSync, writeFileSync} from "fs"; +import {tmpdir} from "os"; +import {join} from "path"; +import { + AcquisitionLot, + AcquisitionResolver, + HashChainAuditLog, + PersonGroupLedger, + RejectionRecord, + TransferAgentProvider, + TransferSurveillance +} from "../src"; + +const holder = "0x00000000000000000000000000000000000000a1" as const; +const asset = "0x00000000000000000000000000000000000000b1" as const; +const other = "0x00000000000000000000000000000000000000c1" as const; +const tx1 = `0x${"1".repeat(64)}` as const; +const tx2 = `0x${"2".repeat(64)}` as const; +const tx3 = `0x${"3".repeat(64)}` as const; +const tx4 = `0x${"4".repeat(64)}` as const; + +async function main(): Promise { + const lots: AcquisitionLot[] = [ + {lotId: "primary", holder, asset, quantity: "100", acquisitionDate: 100, paymentCompleteAt: 120, sourceType: "PRIMARY"}, + {lotId: "dividend", holder, asset, quantity: "1", acquisitionDate: 200, paymentCompleteAt: 200, sourceType: "DIVIDEND", lineageRef: "primary"}, + {lotId: "new", holder, asset, quantity: "5", acquisitionDate: 300, paymentCompleteAt: 320, sourceType: "SECONDARY"} + ]; + const provider: TransferAgentProvider = {async lots() { return lots; }}; + const snapshot = await new AcquisitionResolver(provider, 60).compile(holder, asset, 1_000); + if (snapshot.status !== "VALID" || snapshot.clockStart !== 320 || snapshot.expiresAt !== 1_060) { + throw new Error("conservative acquisition snapshot regression"); + } + + const brokenProvider: TransferAgentProvider = {async lots() { return [{...lots[1], lineageRef: "missing"}]; }}; + const broken = await new AcquisitionResolver(brokenProvider).compile(holder, asset, 1_000); + if (broken.status !== "LINEAGE_BROKEN") throw new Error("broken lineage must fail closed"); + + const cyclicProvider: TransferAgentProvider = { + async lots() { + return [ + {...lots[1], lotId: "gift-a", sourceType: "GIFT", lineageRef: "trust-b"}, + {...lots[1], lotId: "trust-b", sourceType: "TRUST", lineageRef: "gift-a"} + ]; + } + }; + const cyclic = await new AcquisitionResolver(cyclicProvider).compile(holder, asset, 1_000); + if (cyclic.status !== "LINEAGE_BROKEN" || cyclic.clockStart !== 0) { + throw new Error("cyclic lineage must fail closed"); + } + + const secondaryWithLineage: TransferAgentProvider = { + async lots() { + return [lots[0], {...lots[2], lineageRef: "primary"}]; + } + }; + try { + await new AcquisitionResolver(secondaryWithLineage).compile(holder, asset, 1_000); + throw new Error("secondary acquisition inherited an older lineage clock"); + } catch (error: any) { + if (!error.message.includes("secondary lot new cannot inherit lineage")) throw error; + } + + const dir = mkdtempSync(join(tmpdir(), "corner-store-compliance-data-")); + const auditPath = join(dir, "audit.jsonl"); + const audit = new HashChainAuditLog(auditPath); + const rejection: RejectionRecord = { + kind: "REJECTION", + timestamp: 1_000, + attemptTxRef: "simulation:1", + from: holder, + to: other, + tokenIn: other, + tokenOut: asset, + amount: "10", + direction: "BUY", + failedElement: "C-01-v1", + reasonCode: tx1, + attestedFactRefs: [], + reliedExemption: "RULE_144", + riskTier: "LOW" + }; + audit.append(rejection); + const surveillance = new TransferSurveillance(audit); + surveillance.observe({timestamp: 1_001, transactionHash: tx2, token: asset, from: holder, to: other, amount: "1", route: "DIRECT_TRANSFER"}); + surveillance.observe({timestamp: 1_002, transactionHash: tx1, token: asset, from: holder, to: other, amount: "1", route: "APPROVED_ROUTER"}); + if (new HashChainAuditLog(auditPath).list().length !== 2) throw new Error("audit persistence regression"); + + const tampered = readFileSync(auditPath, "utf8").replace('"amount":"10"', '"amount":"11"'); + writeFileSync(auditPath, tampered); + try { + new HashChainAuditLog(auditPath); + throw new Error("tampered audit chain accepted"); + } catch (error: any) { + if (!error.message.includes("hash chain invalid")) throw error; + } + + const ledger = new PersonGroupLedger(); + const first = ledger.commit({ + executionId: tx1, + sellerGroupId: "group-a", + timestamp: Date.UTC(2026, 3, 30) / 1_000, + amount: "25", + holderUpdates: [{groupId: "group-b", isHolder: true, isAccredited: false, isUsResident: true}] + }); + if (!first.applied) throw new Error("first ledger commit not applied"); + const replay = ledger.commit({ + executionId: tx1, + sellerGroupId: "group-a", + timestamp: Date.UTC(2026, 3, 30) / 1_000, + amount: "25", + holderUpdates: [{groupId: "group-b", isHolder: true, isAccredited: false, isUsResident: true}] + }); + if (replay.applied) throw new Error("idempotent replay applied twice"); + try { + ledger.commit({executionId: tx1, sellerGroupId: "group-a", timestamp: Date.UTC(2026, 3, 30) / 1_000, amount: "26"}); + throw new Error("conflicting replay accepted"); + } catch (error: any) { + if (!error.message.includes("conflicting commit replay")) throw error; + } + if (ledger.rollingThreeCalendarMonthVolume("group-a", Date.UTC(2026, 6, 30) / 1_000) !== 25n) { + throw new Error("rolling volume regression"); + } + const counts = ledger.holderCounts(); + if (counts.total !== 1 || counts.nonAccredited !== 1 || counts.usResident !== 1) { + throw new Error("holder counts regression"); + } + ledger.commit({ + executionId: tx3, + sellerGroupId: "group-a", + timestamp: Date.UTC(2026, 4, 1) / 1_000, + amount: "1", + holderUpdates: [{groupId: "group-b", isHolder: true, isAccredited: true, isUsResident: false}] + }); + const updatedCounts = ledger.holderCounts(); + if (updatedCounts.total !== 1 || updatedCounts.nonAccredited !== 0 || updatedCounts.usResident !== 0) { + throw new Error("holder attribute update regression"); + } + ledger.commit({ + executionId: tx4, + sellerGroupId: "group-a", + timestamp: Date.UTC(2026, 4, 2) / 1_000, + amount: "1", + holderUpdates: [{groupId: "group-b", isHolder: false, isAccredited: true, isUsResident: false}] + }); + if (ledger.holderCounts().total !== 0) throw new Error("holder deactivation regression"); + console.log("corner-store compliance data smoke ok"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/services/compliance-data/tsconfig.json b/services/compliance-data/tsconfig.json new file mode 100644 index 0000000..c7023b7 --- /dev/null +++ b/services/compliance-data/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/services/operator-dashboard/README.md b/services/operator-dashboard/README.md index 1aa99eb..d88a219 100644 --- a/services/operator-dashboard/README.md +++ b/services/operator-dashboard/README.md @@ -1,6 +1,11 @@ # Corner Store Operator Dashboard -This is a static, read-only operator view for `services/operator-api`. +This dashboard has two modes: + +- **Operator view** is a read-only view backed by `services/operator-api` through + the dashboard's same-origin proxy. +- **RFQ demo** requests a quote from the local `services/rfq-demo-backend`, then + downloads it and copies the protected CLI settlement command. - It displays the selected asset profile, enabled venues and indexed events. - It never accepts or stores a private key. @@ -8,6 +13,21 @@ This is a static, read-only operator view for `services/operator-api`. - Governance changes must be prepared as an external multisig proposal and reviewed against the deployment artifact before execution. -Serve this directory behind the same origin as the read-only API in a local/demo -environment. Production hosting, authentication, CSRF policy and multisig provider +For the RFQ-first MVP demo: + +```sh +# terminal 1 +scripts/e2e-anvil.sh --profile buidl-like --mode rfq --keep + +# terminal 2 +npm run start --prefix services/operator-dashboard +``` + +Open `http://127.0.0.1:8790` and select a view. The dashboard never has a +private key and does not submit transactions. Start Operator API on port 8788, +or set `CORNER_STORE_OPERATOR_API` to its URL. If the API uses authentication, +set `CORNER_STORE_API_TOKEN` on the dashboard process; the token stays server +side and is never exposed to the browser. + +Production hosting, authentication, CSRF policy and multisig provider integration remain deployment-specific work. diff --git a/services/operator-dashboard/index.html b/services/operator-dashboard/index.html index 7580029..42d322f 100644 --- a/services/operator-dashboard/index.html +++ b/services/operator-dashboard/index.html @@ -1,40 +1,22 @@ - - - Corner Store Operator + + Corner Store Console - -

Corner Store Operator

-

Read-only control plane. Transactions must be proposed and approved through the configured multisig.

- -
-

Asset

Loading…

-

Venues

Loading…

-

Safety boundary

No private keys. No direct transaction endpoint.

Use the API snapshot to create an external multisig proposal.

-
-

Indexed events

Loading…
- - - +
+
Corner Store / local console

Compliance, visible
at settlement.

Read deployment state in Operator view. In RFQ demo, click through a signed quote, current-policy check and protected Router settlement.

+
PROFILELoading…
VENUESLoading…
CONTROL PLANERead-only snapshot
CHANGE PATHExternal multisig proposal

Indexed events

Loading…

No private keys. No direct transaction endpoint.

+ +
diff --git a/services/operator-dashboard/package.json b/services/operator-dashboard/package.json index a7348e4..2f957fd 100644 --- a/services/operator-dashboard/package.json +++ b/services/operator-dashboard/package.json @@ -4,6 +4,7 @@ "private": true, "description": "Read-only operator dashboard for the Corner Store API.", "scripts": { + "start": "node server.js", "test": "node test/smoke.js" } } diff --git a/services/operator-dashboard/server.js b/services/operator-dashboard/server.js new file mode 100644 index 0000000..60217cc --- /dev/null +++ b/services/operator-dashboard/server.js @@ -0,0 +1,49 @@ +const {createServer, request: requestUpstream} = require("http"); +const {readFileSync} = require("fs"); +const {join} = require("path"); + +const port = Number(process.env.PORT || 8790); +const operatorApiUrl = new URL(process.env.CORNER_STORE_OPERATOR_API || "http://127.0.0.1:8788"); +const operatorApiToken = process.env.CORNER_STORE_API_TOKEN; +const page = readFileSync(join(__dirname, "index.html")); + +createServer((req, res) => { + const path = new URL(req.url || "/", "http://127.0.0.1").pathname; + if (req.method === "GET" && (path === "/" || path === "/index.html")) { + res.writeHead(200, {"content-type": "text/html; charset=utf-8", "cache-control": "no-store"}); + return res.end(page); + } + if (req.method === "GET" && path === "/health") { + res.writeHead(200, {"content-type": "application/json; charset=utf-8"}); + return res.end(`${JSON.stringify({ok: true, service: "corner-store-operator-dashboard", operatorApi: operatorApiUrl.href})}\n`); + } + if (req.method === "GET" && path.startsWith("/api/v1/")) { + return proxyOperatorApi(req, res); + } + res.writeHead(404, {"content-type": "application/json; charset=utf-8"}); + res.end('{"error":"not_found"}\n'); +}).listen(port, "127.0.0.1", () => console.log(`Corner Store dashboard listening at http://127.0.0.1:${port}`)); + +function proxyOperatorApi(req, res) { + const upstream = requestUpstream({ + protocol: operatorApiUrl.protocol, + hostname: operatorApiUrl.hostname, + port: operatorApiUrl.port, + path: `${operatorApiUrl.pathname.replace(/\/$/, "")}${new URL(req.url || "/", "http://127.0.0.1").pathname}`, + method: "GET", + headers: operatorApiToken ? {authorization: `Bearer ${operatorApiToken}`} : {} + }, (response) => { + res.writeHead(response.statusCode || 502, { + "content-type": response.headers["content-type"] || "application/json; charset=utf-8", + "cache-control": "no-store" + }); + response.pipe(res); + }); + upstream.setTimeout(5000, () => upstream.destroy(new Error("operator API timeout"))); + upstream.on("error", (error) => { + if (res.headersSent) return res.destroy(error); + res.writeHead(502, {"content-type": "application/json; charset=utf-8"}); + res.end(JSON.stringify({error: "operator_api_unavailable", message: error.message}) + "\n"); + }); + upstream.end(); +} diff --git a/services/operator-dashboard/test/smoke.js b/services/operator-dashboard/test/smoke.js index 80a1cb8..e9a2d73 100644 --- a/services/operator-dashboard/test/smoke.js +++ b/services/operator-dashboard/test/smoke.js @@ -2,7 +2,11 @@ const fs = require("fs"); const path = require("path"); const html = fs.readFileSync(path.join(__dirname, "..", "index.html"), "utf8"); -for (const marker of ["/api/v1/config", "/api/v1/events", "No private keys", "multisig"]) { +for (const marker of ["/api/v1/config", "/api/v1/events", "No private keys", "multisig", "RFQ demo", "Run compliant RFQ trade", "Revoke maker & retry", "/demo/trade", "Router → RFQAdapter"]) { if (!html.includes(marker)) throw new Error(`dashboard safety/data marker missing: ${marker}`); } +const server = fs.readFileSync(path.join(__dirname, "..", "server.js"), "utf8"); +for (const marker of ["CORNER_STORE_OPERATOR_API", "CORNER_STORE_API_TOKEN", "proxyOperatorApi", "operator_api_unavailable"]) { + if (!server.includes(marker)) throw new Error(`dashboard proxy marker missing: ${marker}`); +} console.log("corner-store operator dashboard smoke ok"); diff --git a/services/rfq-demo-backend/README.md b/services/rfq-demo-backend/README.md index 1ac1e8c..0cd5676 100644 --- a/services/rfq-demo-backend/README.md +++ b/services/rfq-demo-backend/README.md @@ -23,7 +23,8 @@ npm start ``` The defaults use `deployments/anvil-e2e.json`, Anvil account 2 as the approved -maker, chain id 31337, a 1:1 fixed rate and one-hour quote expiry. +maker, chain id 31337, `http://127.0.0.1:8545` for the current chain timestamp, +a 1:1 fixed rate and one-hour quote expiry. ## API @@ -45,25 +46,33 @@ The backend fixes `tokenIn`, `tokenOut`, RFQ venue and verifying contract to the deployment artifact. The response is the existing `{quote, signature, typedData}` format consumed by the CLI and `RFQAdapter`. -The easiest end-user flow is: +For the click-through local demo, run: ```sh -node services/cli/dist/cli/src/index.js rfq-quote \ - --backend http://127.0.0.1:8787 --amount-in 5000000 --out quote.json -node services/cli/dist/cli/src/index.js buy 0 --venue rfq --quote quote.json +scripts/e2e-anvil.sh --profile buidl-like --mode rfq --keep +npm run start --prefix services/operator-dashboard ``` -`rfq-quote` verifies the returned taker, pair, venue, chain, RFQ adapter and maker -signature before writing the quote file. +Open `http://127.0.0.1:8790`, select **RFQ demo**, and click **Run compliant +RFQ trade**. The runner enables `/demo/trade` only for this local Anvil setup: +it uses the selected local demo profile, submits through `ExecutionRouter`, and +never sends a private key to the browser. **Revoke maker & retry** proves that +current policy still rejects a previously signable quote. ## Configuration Command flags have matching `RFQ_DEMO_*` environment variables: -- `--host`, `--port`, `--artifact`, `--chain-id` +- `--host`, `--port`, `--artifact`, `--chain-id`, `--rpc` - `--maker-account` or `--maker-key` - `--ttl` - `--price-numerator`, `--price-denominator` +- `RFQ_DEMO_OPERATOR_ACCOUNT`, `RFQ_DEMO_INVESTOR_ACCOUNT` (local Anvil + account indexes used only when demo settlement is explicitly enabled) + +`RFQ_DEMO_ENABLE_SETTLEMENT=1` additionally enables the click-through settlement +endpoint. It is set by `scripts/e2e-anvil.sh` only after it has started local +Anvil. Do not enable or expose this endpoint for a hosted service. Run `npm start -- --help` for defaults. Never commit a maker key. diff --git a/services/rfq-demo-backend/src/config.ts b/services/rfq-demo-backend/src/config.ts index 347e9af..b6795f9 100644 --- a/services/rfq-demo-backend/src/config.ts +++ b/services/rfq-demo-backend/src/config.ts @@ -10,24 +10,33 @@ export const DEFAULT_PORT = 8787; export const DEFAULT_TTL_SECONDS = 3600; export interface Artifact { + deployer: string; investor: string; maker: string; quote: string; rfqAdapter: string; rfqVenue: string; rwaToken: string; + router: string; } export interface DemoBackendConfig { host: string; port: number; chainId: number; + rpcUrl: string; artifactPath: string; artifact: Artifact; makerWallet: Wallet | HDNodeWallet; defaultTtlSeconds: number; priceNumerator: string; priceDenominator: string; + demoSettlement: { + enabled: boolean; + operatorAccount: number; + investorAccount: number; + }; + now?: () => number | Promise; } export function loadConfig(argv = process.argv.slice(2), env = process.env): DemoBackendConfig { @@ -42,12 +51,18 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env): Dem host: args.host ?? env.RFQ_DEMO_HOST ?? "127.0.0.1", port: parsePositiveInt(args.port ?? env.RFQ_DEMO_PORT, DEFAULT_PORT, "port"), chainId: parsePositiveInt(args.chainId ?? env.RFQ_DEMO_CHAIN_ID, DEFAULT_CHAIN_ID, "chain-id"), + rpcUrl: args.rpc ?? env.RFQ_DEMO_RPC_URL ?? "http://127.0.0.1:8545", artifactPath, artifact, makerWallet, defaultTtlSeconds, priceNumerator: parsePositiveBigIntString(args.priceNumerator ?? env.RFQ_DEMO_PRICE_NUMERATOR, "1", "price-numerator"), - priceDenominator: parsePositiveBigIntString(args.priceDenominator ?? env.RFQ_DEMO_PRICE_DENOMINATOR, "1", "price-denominator") + priceDenominator: parsePositiveBigIntString(args.priceDenominator ?? env.RFQ_DEMO_PRICE_DENOMINATOR, "1", "price-denominator"), + demoSettlement: { + enabled: env.RFQ_DEMO_ENABLE_SETTLEMENT === "1", + operatorAccount: parseAccountIndex(env.RFQ_DEMO_OPERATOR_ACCOUNT ?? "0"), + investorAccount: parseAccountIndex(env.RFQ_DEMO_INVESTOR_ACCOUNT ?? "1") + } }; } @@ -60,6 +75,7 @@ export function usage(): string { " --port bind port (default: 8787)", " --artifact deployment artifact (default: deployments/anvil-e2e.json)", " --chain-id RFQ EIP-712 chain id (default: 31337)", + " --rpc chain RPC used for quote expiry time (default: http://127.0.0.1:8545)", " --maker-account Anvil maker account index (default: 2)", " --maker-key explicit maker private key", " --ttl default quote TTL (default: 3600)", diff --git a/services/rfq-demo-backend/src/server.ts b/services/rfq-demo-backend/src/server.ts index 4ade27f..f3a8e39 100644 --- a/services/rfq-demo-backend/src/server.ts +++ b/services/rfq-demo-backend/src/server.ts @@ -4,6 +4,7 @@ import {RFQBackendSDK, SignedRFQQuote} from "../../rfq/src"; import {DemoBackendConfig, asAddress} from "./config"; import {createDemoQuoteService} from "./service"; +import {DemoSettlementService, DemoTradeAction} from "./settlement"; const MAX_BODY_BYTES = 16 * 1024; @@ -21,8 +22,9 @@ export interface DemoServer { export async function startDemoServer(config: DemoBackendConfig): Promise { const quoteService = await createDemoQuoteService(config); + const settlement = config.demoSettlement.enabled ? new DemoSettlementService(config, quoteService) : undefined; const server = createServer((req, res) => { - void handleRequest(req, res, config, quoteService); + void handleRequest(req, res, config, quoteService, settlement); }); await new Promise((resolve, reject) => { @@ -49,9 +51,16 @@ async function handleRequest( req: IncomingMessage, res: ServerResponse, config: DemoBackendConfig, - quoteService: RFQBackendSDK + quoteService: RFQBackendSDK, + settlement?: DemoSettlementService ): Promise { try { + setDemoCors(req, res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } if (req.method === "GET" && req.url === "/health") { sendJson(res, 200, { status: "ok", @@ -61,7 +70,8 @@ async function handleRequest( verifyingContract: config.artifact.rfqAdapter, venue: config.artifact.rfqVenue, tokenIn: config.artifact.quote, - tokenOut: config.artifact.rwaToken + tokenOut: config.artifact.rwaToken, + demoSettlementEnabled: Boolean(settlement) }); return; } @@ -73,6 +83,17 @@ async function handleRequest( return; } + if (req.method === "POST" && req.url === "/demo/trade") { + if (!settlement) { + sendJson(res, 403, {error: "demo_settlement_disabled", message: "demo settlement is available only from the local e2e runner"}); + return; + } + const body = await readJsonBody(req); + const {amountIn, action} = parseDemoTrade(body); + sendJson(res, 200, await settlement.trade(amountIn, action)); + return; + } + sendJson(res, 404, {error: "not_found"}); } catch (error) { const message = error instanceof Error ? error.message : "unknown error"; @@ -80,6 +101,28 @@ async function handleRequest( } } +function parseDemoTrade(body: unknown): {amountIn: string; action: DemoTradeAction} { + if (!isRecord(body)) throw new Error("request body must be a JSON object"); + if (typeof body.amountIn !== "string" || !/^\d+$/.test(body.amountIn) || BigInt(body.amountIn) <= 0n) { + throw new Error("amountIn must be a positive base-unit uint string"); + } + const action = body.action ?? "settle"; + if (action !== "settle" && action !== "revoked-maker") throw new Error("action must be settle or revoked-maker"); + return {amountIn: body.amountIn, action}; +} + +function setDemoCors(req: IncomingMessage, res: ServerResponse): void { + // This service is intentionally local-only. Permit only the shipped local + // dashboard origin rather than letting arbitrary web pages query a signer. + const origin = req.headers.origin; + if (origin === "http://127.0.0.1:8790" || origin === "http://localhost:8790") { + res.setHeader("access-control-allow-origin", origin); + res.setHeader("vary", "origin"); + } + res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); +} + async function createQuote( body: unknown, config: DemoBackendConfig, diff --git a/services/rfq-demo-backend/src/service.ts b/services/rfq-demo-backend/src/service.ts index 28c06fd..18239e9 100644 --- a/services/rfq-demo-backend/src/service.ts +++ b/services/rfq-demo-backend/src/service.ts @@ -5,6 +5,7 @@ import { RFQBackendSDK, createRFQService } from "../../rfq/src"; +import {JsonRpcProvider} from "ethers"; import {DemoBackendConfig, asAddress} from "./config"; import {EthersTypedDataSigner} from "./signer"; @@ -18,6 +19,12 @@ export async function createDemoQuoteService(config: DemoBackendConfig): Promise if (maker.toLowerCase() !== artifactMaker.toLowerCase()) { throw new Error(`maker wallet ${maker} does not match deployment artifact maker ${artifactMaker}`); } + const provider = new JsonRpcProvider(config.rpcUrl, config.chainId); + const now = config.now ?? (async () => { + const block = await provider.getBlock("latest"); + if (!block) throw new Error("cannot read latest block timestamp from RFQ chain RPC"); + return block.timestamp; + }); return createRFQService({ chainId: config.chainId, @@ -30,6 +37,7 @@ export async function createDemoQuoteService(config: DemoBackendConfig): Promise }), nonceStore: new InMemoryNonceStore(), riskCheck: new NoopInventoryRiskCheck(), - defaultTtlSeconds: config.defaultTtlSeconds + defaultTtlSeconds: config.defaultTtlSeconds, + now }); } diff --git a/services/rfq-demo-backend/src/settlement.ts b/services/rfq-demo-backend/src/settlement.ts new file mode 100644 index 0000000..b01dc60 --- /dev/null +++ b/services/rfq-demo-backend/src/settlement.ts @@ -0,0 +1,133 @@ +import {AbiCoder, Contract, HDNodeWallet, JsonRpcProvider, MaxUint256, NonceManager, formatEther} from "ethers"; + +import {RFQBackendSDK, SignedRFQQuote} from "../../rfq/src"; + +import {ANVIL_MNEMONIC, DemoBackendConfig, asAddress} from "./config"; + +const ROUTER_ABI = [ + "function execute((tuple(address initiator,address buyer,address seller,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint8 venueType,address venue,uint8 flowType,bool sellerIsAffiliate) context,uint256 amountOutMin,uint64 deadline,uint256 nonce,bytes venueData) req) returns (tuple(uint256 amountOut,bytes32 executionId))" +]; +const ERC20_ABI = [ + "function balanceOf(address account) view returns (uint256)", + "function allowance(address owner,address spender) view returns (uint256)", + "function approve(address spender,uint256 amount) returns (bool)" +]; +const RFQ_ADAPTER_ABI = ["function setMakerApproved(address maker,bool approved)"]; +const QUOTE_TUPLE = "tuple(address maker,address taker,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,address venue,uint256 nonce,uint64 expiry)"; + +export type DemoTradeAction = "settle" | "revoked-maker"; + +export interface DemoTradeResult { + action: DemoTradeAction; + quote: SignedRFQQuote; + trace: Array<{stage: string; detail: string; status: "passed" | "rejected"}>; + transaction?: {hash: string; blockNumber: number; rwaBefore: string; rwaAfter: string; rwaDelta: string}; + rejection?: string; +} + +/** Local-Anvil-only facilitator. Never use this pattern for a hosted service. */ +export class DemoSettlementService { + private readonly provider: JsonRpcProvider; + private readonly investor: NonceManager; + private readonly operator: NonceManager; + private readonly investorAddress: string; + private nextRouterNonce = BigInt(Date.now()); + + constructor(private readonly config: DemoBackendConfig, private readonly quotes: RFQBackendSDK) { + if (!config.demoSettlement.enabled) throw new Error("demo settlement is disabled"); + if (config.chainId !== 31337) throw new Error("demo settlement is restricted to Anvil chain id 31337"); + + const investor = demoWallet(config.demoSettlement.investorAccount); + const operator = demoWallet(config.demoSettlement.operatorAccount); + this.investorAddress = investor.address; + if (investor.address.toLowerCase() !== asAddress(config.artifact.investor, "artifact investor").toLowerCase()) { + throw new Error("demo settlement requires the canonical Anvil investor account"); + } + if (operator.address.toLowerCase() !== asAddress(config.artifact.deployer, "artifact deployer").toLowerCase()) { + throw new Error("demo settlement requires the canonical Anvil operator account"); + } + + this.provider = new JsonRpcProvider(config.rpcUrl, config.chainId); + this.investor = new NonceManager(investor.connect(this.provider)); + this.operator = new NonceManager(operator.connect(this.provider)); + } + + async trade(amountIn: string, action: DemoTradeAction): Promise { + const signed = await this.quotes.quote({ + taker: asAddress(this.config.artifact.investor, "artifact investor"), + tokenIn: asAddress(this.config.artifact.quote, "artifact quote"), + tokenOut: asAddress(this.config.artifact.rwaToken, "artifact rwaToken"), + amountIn, + venue: asAddress(this.config.artifact.rfqVenue, "artifact rfqVenue") + }); + const trace: DemoTradeResult["trace"] = [ + {stage: "Mock TA profile", detail: "canonical demo investor selected", status: "passed"}, + {stage: "RFQ quote", detail: `maker signed nonce ${signed.quote.nonce}`, status: "passed"} + ]; + + let makerRevoked = false; + try { + if (action === "revoked-maker") { + await this.setMakerApproval(false); + makerRevoked = true; + trace.push({stage: "Maker policy", detail: "operator revoked maker before fill", status: "rejected"}); + } + return await this.execute(signed, action, trace); + } finally { + if (makerRevoked) await this.setMakerApproval(true); + } + } + + private async execute(signed: SignedRFQQuote, action: DemoTradeAction, trace: DemoTradeResult["trace"]): Promise { + const q = signed.quote; + const rwa = new Contract(this.config.artifact.rwaToken, ERC20_ABI, this.provider); + const quote = new Contract(this.config.artifact.quote, ERC20_ABI, this.investor); + const router = new Contract(this.config.artifact.router, ROUTER_ABI, this.investor); + const before = await rwa.balanceOf(this.investorAddress) as bigint; + const amountIn = BigInt(q.amountIn); + const allowance = await quote.allowance(this.investorAddress, this.config.artifact.rfqAdapter) as bigint; + if (allowance < amountIn) { + const approval = await quote.approve(this.config.artifact.rfqAdapter, MaxUint256); + await approval.wait(); + trace.push({stage: "Quote allowance", detail: "demo investor approved RFQAdapter", status: "passed"}); + } + + const latest = await this.provider.getBlock("latest"); + if (!latest) throw new Error("cannot read latest block for demo settlement"); + const venueData = AbiCoder.defaultAbiCoder().encode( + [QUOTE_TUPLE, "bytes"], + [[q.maker, q.taker, q.tokenIn, q.tokenOut, q.amountIn, q.amountOut, q.venue, q.nonce, q.expiry], signed.signature] + ); + const context = [this.investorAddress, this.investorAddress, q.maker, q.tokenIn, q.tokenOut, q.amountIn, q.amountOut, 2, q.venue, 0, false]; + const request = [context, q.amountOut, BigInt(latest.timestamp + 3600), this.nextRouterNonce++, venueData]; + + try { + const tx = await router.execute(request); + const receipt = await tx.wait(); + const after = await rwa.balanceOf(this.investorAddress) as bigint; + trace.push({stage: "ComplianceEngine", detail: "latest policy accepted at fill time", status: "passed"}); + trace.push({stage: "RFQ settlement", detail: `ERC-3643 balance +${formatEther(after - before)}`, status: "passed"}); + return { + action, + quote: signed, + trace, + transaction: {hash: tx.hash, blockNumber: receipt?.blockNumber ?? 0, rwaBefore: before.toString(), rwaAfter: after.toString(), rwaDelta: (after - before).toString()} + }; + } catch (error) { + const detail = error instanceof Error ? (error as any).shortMessage ?? error.message : String(error); + trace.push({stage: "RFQ settlement", detail, status: "rejected"}); + if (action !== "revoked-maker") throw error; + return {action, quote: signed, trace, rejection: detail}; + } + } + + private async setMakerApproval(approved: boolean): Promise { + const adapter = new Contract(this.config.artifact.rfqAdapter, RFQ_ADAPTER_ABI, this.operator); + const tx = await adapter.setMakerApproved(this.config.artifact.maker, approved); + await tx.wait(); + } +} + +function demoWallet(account: number): HDNodeWallet { + return HDNodeWallet.fromPhrase(ANVIL_MNEMONIC, "", `m/44'/60'/0'/0/${account}`); +} diff --git a/services/rfq-demo-backend/test/smoke.ts b/services/rfq-demo-backend/test/smoke.ts index a7624c9..4d1ef74 100644 --- a/services/rfq-demo-backend/test/smoke.ts +++ b/services/rfq-demo-backend/test/smoke.ts @@ -14,12 +14,14 @@ async function main(): Promise { const dir = mkdtempSync(join(tmpdir(), "corner-store-rfq-demo-")); const artifactPath = join(dir, "anvil-e2e.json"); const artifact = { + deployer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", investor: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", maker: await makerWallet.getAddress(), quote: "0x0B306BF915C4d645ff596e518fAf3F9669b97016", rfqAdapter: "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0", rfqVenue: "0x000000000000000000000000000000000000F00D", - rwaToken: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + rwaToken: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + router: "0x5FbDB2315678afecb367f032d93F642f64180aa3" }; writeFileSync(artifactPath, JSON.stringify(artifact)); @@ -27,20 +29,28 @@ async function main(): Promise { host: "127.0.0.1", port: 0, chainId: 31337, + rpcUrl: "http://127.0.0.1:8545", artifactPath, artifact, makerWallet, defaultTtlSeconds: 3600, priceNumerator: "2", - priceDenominator: "1" + priceDenominator: "1", + demoSettlement: {enabled: false, operatorAccount: 0, investorAccount: 1}, + now: () => 1_700_000_000 }; const running = await startDemoServer(config); try { - const health = await requestJson(`${running.baseUrl}/health`, "GET"); + const health = await requestJson(`${running.baseUrl}/health`, "GET", undefined, {origin: "http://127.0.0.1:8790"}); assert(health.status === 200, "health returns 200"); + assert(health.headers["access-control-allow-origin"] === "http://127.0.0.1:8790", "dashboard-only CORS is enabled for the local demo"); const healthBody = JSON.parse(health.body) as any; assert(healthBody.status === "ok", "health reports ok"); + assert(healthBody.demoSettlementEnabled === false, "settlement is opt-in"); + + const disabledTrade = await requestJson(`${running.baseUrl}/demo/trade`, "POST", {amountIn: "100", action: "settle"}); + assert(disabledTrade.status === 403, "settlement endpoint is unavailable outside the local runner"); const quoteResponse = await requestJson(`${running.baseUrl}/rfq/quote`, "POST", { taker: artifact.investor, @@ -51,6 +61,7 @@ async function main(): Promise { const signed = JSON.parse(quoteResponse.body) as any; assert(signed.quote.amountIn === "100", "amountIn round-trips"); assert(signed.quote.amountOut === "200", "fixed-rate pricing is applied"); + assert(signed.quote.expiry === 1_700_000_120, "expiry uses injected chain clock"); assert(signed.quote.tokenIn.toLowerCase() === artifact.quote.toLowerCase(), "tokenIn is deployment QUOTE"); assert(signed.quote.tokenOut.toLowerCase() === artifact.rwaToken.toLowerCase(), "tokenOut is deployment RWA"); const recovered = verifyTypedData(signed.typedData.domain, RFQ_QUOTE_TYPES, signed.quote, signed.signature); @@ -79,21 +90,19 @@ function assert(condition: boolean, message: string): void { if (!condition) throw new Error(`assertion failed: ${message}`); } -function requestJson(urlValue: string, method: "GET" | "POST", value?: unknown): Promise<{status: number; body: string}> { +function requestJson(urlValue: string, method: "GET" | "POST", value?: unknown, extraHeaders?: Record): Promise<{status: number; body: string; headers: Record}> { const body = value === undefined ? "" : JSON.stringify(value); return new Promise((resolve, reject) => { const req = httpRequest( urlValue, { method, - headers: body - ? {"content-type": "application/json", "content-length": Buffer.byteLength(body)} - : undefined + headers: {...extraHeaders, ...(body ? {"content-type": "application/json", "content-length": Buffer.byteLength(body)} : {})} }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - res.on("end", () => resolve({status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8")})); + res.on("end", () => resolve({status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8"), headers: res.headers})); } ); req.on("error", reject); diff --git a/services/rfq/src/quoteService.ts b/services/rfq/src/quoteService.ts index 7ecc0f0..8a292a6 100644 --- a/services/rfq/src/quoteService.ts +++ b/services/rfq/src/quoteService.ts @@ -50,7 +50,7 @@ export class RFQQuoteService { } async createSignedQuote(request: RFQQuoteRequest): Promise { - const quote = this.createQuote(request); + const quote = this.createQuoteAt(request, normalizeTimestamp(await this.config.now())); const data = typedData(domain(this.config.chainId, this.config.verifyingContract), quote); const signature = assertHex(await this.signer.signTypedData(data), "signature"); @@ -58,6 +58,14 @@ export class RFQQuoteService { } createQuote(request: RFQQuoteRequest): RFQQuote { + const now = this.config.now(); + if (now instanceof Promise) { + throw new Error("async time source requires createSignedQuote()"); + } + return this.createQuoteAt(request, normalizeTimestamp(now)); + } + + private createQuoteAt(request: RFQQuoteRequest, now: number): RFQQuote { const ttlSeconds = normalizeTtlSeconds(request.ttlSeconds ?? this.config.defaultTtlSeconds); return { @@ -69,7 +77,7 @@ export class RFQQuoteService { amountOut: toPositiveUintString(request.amountOut, "amountOut"), venue: normalizeAddress(request.venue, "venue"), nonce: toUintString(request.nonce ?? this.config.nextNonce(), "nonce"), - expiry: this.config.now() + ttlSeconds + expiry: now + ttlSeconds }; } } @@ -79,7 +87,7 @@ export class RFQBackendSDK { private readonly verifyingContract: Address; private readonly maker: Address; private readonly defaultTtlSeconds: number; - private readonly now: () => number; + private readonly now: () => number | Promise; private readonly nonceStore: NonceStore; private readonly riskCheck: InventoryRiskCheck; @@ -164,3 +172,8 @@ function createMonotonicNonceGenerator(): () => bigint { return lastNonce; }; } + +function normalizeTimestamp(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("time source must return a non-negative safe integer"); + return value; +} diff --git a/services/rfq/src/types.ts b/services/rfq/src/types.ts index 9d99e8e..322e311 100644 --- a/services/rfq/src/types.ts +++ b/services/rfq/src/types.ts @@ -100,7 +100,7 @@ export interface RFQServiceConfig { chainId: number; verifyingContract: Address; defaultTtlSeconds?: number; - now?: () => number; + now?: () => number | Promise; nextNonce?: () => bigint; } @@ -113,5 +113,5 @@ export interface RFQBackendSDKConfig { nonceStore?: NonceStore; riskCheck?: InventoryRiskCheck; defaultTtlSeconds?: number; - now?: () => number; + now?: () => number | Promise; } diff --git a/services/rfq/test/smoke.ts b/services/rfq/test/smoke.ts index c6590ff..1732f4e 100644 --- a/services/rfq/test/smoke.ts +++ b/services/rfq/test/smoke.ts @@ -119,6 +119,24 @@ async function highLevelSdkSmoke() { assert(signer.calls === 1, "SDK signed once"); assert(signed.typedData.message.amountOut === signed.quote.amountOut, "SDK typed data binds price"); + const asyncClock = createRFQService({ + chainId: 31337, + verifyingContract: ADAPTER, + maker: MAKER, + signer, + pricing: new FixedRatePricingProvider({numerator: 1n, denominator: 1n}), + now: async () => 1_800_000_000, + defaultTtlSeconds: 60 + }); + const chainTimed = await asyncClock.quote({ + taker: TAKER, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: 10n, + venue: VENUE + }); + assert(chainTimed.quote.expiry === 1_800_000_060, "SDK supports async chain clock"); + const second = await rfq.quote({taker: TAKER, tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, amountIn: 10n, venue: VENUE}); assert(BigInt(second.quote.nonce) > BigInt(signed.quote.nonce), "SDK nonce store is monotonic"); diff --git a/src/compliance/ComplianceEngine.sol b/src/compliance/ComplianceEngine.sol index e8d2e13..a9040b6 100644 --- a/src/compliance/ComplianceEngine.sol +++ b/src/compliance/ComplianceEngine.sol @@ -12,6 +12,8 @@ import { ComplianceDecision, ManifestCore, PolicyStatus, + RecipeBinding, + RecipeBindingMode, Statefulness } from "../types/ComplianceTypes.sol"; import {DecisionHashLib} from "../libraries/DecisionHashLib.sol"; @@ -19,41 +21,47 @@ import {ReasonCodes} from "../libraries/ReasonCodes.sol"; import {Errors} from "../libraries/Errors.sol"; import {Governed} from "../auth/Governed.sol"; -/// @dev Multi-recipe cumulative-AND compliance engine. Resolves every ACTIVE -/// side in a context, collects applicable recipes, unions their required -/// elements, and ANDs every element's check. Any side that is neither -/// ACTIVE nor explicitly UNREGULATED fails closed. +/// @notice Bounded RecipeBinding evaluator for both sides of a pair. +/// @dev REQUIRED bindings compose as AND. PATH_OPTION bindings compose as OR +/// inside a pathGroupId and as AND across groups. FLAG_ONLY failures are +/// surfaced in flagsBitmap without changing the blocking verdict. contract ComplianceEngine is IComplianceEngine, Governed { + uint256 public constant MAX_RECIPE_BINDINGS = 8; + uint256 public constant MAX_ELEMENTS_PER_RECIPE = 32; + + struct EvaluationState { + bool allowed; + bytes32 reasonCode; + uint8 reasonPriority; + uint256 flagsBitmap; + } + + struct PathState { + uint16[] ids; + bool[] passed; + bytes32[] reasonCodes; + uint8[] priorities; + uint256 count; + } + struct ElementAccumulator { bytes32[] ids; address[] tokens; - uint16[] contributingRecipeIds; uint256 count; } - struct ActivePairState { - ManifestCore manifestIn; - ManifestCore manifestOut; - uint256 elementCapacity; - uint256 allowedVenueTypes; - uint64 policyVersion; - bytes32 policyId; - ElementAccumulator elements; + struct CommitPathState { + uint16[] ids; + uint256[] selected; + uint8[] priorities; + uint256 count; } ITokenPolicyRegistry public immutable policyReg; IElementRegistry public immutable elementReg; IRecipeRegistry public immutable recipeReg; - - /// @dev The sole authorized caller of `commit` (the post-trade write path). - /// Set once by the owner after the router is deployed (the router takes - /// the engine in its constructor, so the engine cannot know it at - /// construction time). Gating `commit` enforces spec §6: runtime counters - /// are written only via engine commit driven by the router, never forged - /// directly by an operator/EOA. address public router; - /// @dev `commit` mutates stateful elements; only the router may drive it. modifier onlyRouter() { if (msg.sender != router) revert Errors.NotAuthorized(); _; @@ -65,56 +73,21 @@ contract ComplianceEngine is IComplianceEngine, Governed { recipeReg = recipeReg_; } - /// @dev One-time/owner-gated router wiring. Concrete-only (not on - /// IComplianceEngine): `evaluate`/`commit` signatures are unchanged. function setRouter(address r) external onlyOwner { router = r; } - // --------------------------------------------------------------------- - // Regulated-token evaluation rule (two-sided, documented, DEFAULT-DENY): - // We read BOTH sides' status and decide the pair outcome fail-closed. Each - // side is checked against a POSITIVE ALLOWLIST — it must be either: - // * UNREGULATED → explicitly declared out-of-scope (quote/cash), or - // * ACTIVE → an operator-approved manifest. - // ANY other status — UNKNOWN (never registered / no inferred UNREGULATED), - // SUSPENDED (kill switch), PROPOSED (declared but not yet approved), RETIRED - // (terminal), and any member appended to PolicyStatus in the future — fails - // closed. Enumerating the permitted states rather than the rejected ones is - // what keeps a newly-added status fail-closed by default instead of silently - // slipping through a gap in a reject list. - // Once both sides are permitted: - // * BOTH UNREGULATED → pass through (fast path). - // * At least one ACTIVE → every ACTIVE side's manifest/recipes are - // evaluated against the full context. Regulated-regulated pairs combine - // both sides rather than choosing one. - // This selection is inlined in `evaluate` (see below); status reads are done - // once there for both sides. - // --------------------------------------------------------------------- function evaluate(ComplianceContext calldata ctx) external view override returns (ComplianceDecision memory) { PolicyStatus statusIn = policyReg.statusOf(ctx.tokenIn); PolicyStatus statusOut = policyReg.statusOf(ctx.tokenOut); - - // (1) Default-deny: each side must be on the positive allowlist - // {UNREGULATED, ACTIVE}. Anything else fails closed, reporting the - // offending side's own status in the reason code (tokenIn first). - if (!_isPermitted(statusIn)) { - return _rejectPolicy(ctx, statusIn); - } - if (!_isPermitted(statusOut)) { - return _rejectPolicy(ctx, statusOut); - } - // (2) Both sides ∈ {UNREGULATED, ACTIVE}. - // Both UNREGULATED → fast path pass-through. - if (statusOut == PolicyStatus.UNREGULATED && statusIn == PolicyStatus.UNREGULATED) { + if (!_isPermitted(statusIn)) return _rejectPolicy(ctx, statusIn); + if (!_isPermitted(statusOut)) return _rejectPolicy(ctx, statusOut); + if (statusIn == PolicyStatus.UNREGULATED && statusOut == PolicyStatus.UNREGULATED) { return _passThrough(ctx); } return _evaluateActivePair(ctx, statusIn, statusOut); } - /// @dev Positive allowlist for a trade side: only an explicitly out-of-scope - /// (UNREGULATED) or operator-approved (ACTIVE) manifest may trade. Every - /// other status — present or future — is denied by omission. function _isPermitted(PolicyStatus status) private pure returns (bool) { return status == PolicyStatus.UNREGULATED || status == PolicyStatus.ACTIVE; } @@ -122,102 +95,153 @@ contract ComplianceEngine is IComplianceEngine, Governed { function _evaluateActivePair(ComplianceContext calldata ctx, PolicyStatus statusIn, PolicyStatus statusOut) internal view - returns (ComplianceDecision memory d) + returns (ComplianceDecision memory) { - ActivePairState memory s; - s.allowedVenueTypes = type(uint256).max; + EvaluationState memory state; + state.allowed = true; + uint256 allowedVenueTypes = type(uint256).max; + uint64 policyVersion; + bytes32 policyId; + uint256 bindingOffset; if (statusIn == PolicyStatus.ACTIVE) { - s.manifestIn = policyReg.manifestOf(ctx.tokenIn); - s.elementCapacity += _maxElements(s.manifestIn); - s.allowedVenueTypes &= uint256(s.manifestIn.supportedEngines); - s.policyVersion = _max64(s.policyVersion, s.manifestIn.issuanceRecipeVersion); - s.policyId = _accumulatePolicyId(s.policyId, ctx.tokenIn, s.manifestIn); + ManifestCore memory manifestIn = policyReg.manifestOf(ctx.tokenIn); + RecipeBinding[] memory bindingsIn = policyReg.recipeBindingsOf(ctx.tokenIn); + allowedVenueTypes &= uint256(manifestIn.supportedEngines); + policyVersion = _max64(policyVersion, policyReg.manifestVersionOf(ctx.tokenIn)); + policyId = _accumulatePolicyId(policyId, ctx.tokenIn, manifestIn, bindingsIn); + _evaluateBindings(ctx, ctx.tokenIn, manifestIn, bindingsIn, bindingOffset, state); + bindingOffset += bindingsIn.length; } if (statusOut == PolicyStatus.ACTIVE) { - s.manifestOut = policyReg.manifestOf(ctx.tokenOut); - s.elementCapacity += _maxElements(s.manifestOut); - s.allowedVenueTypes &= uint256(s.manifestOut.supportedEngines); - s.policyVersion = _max64(s.policyVersion, s.manifestOut.issuanceRecipeVersion); - s.policyId = _accumulatePolicyId(s.policyId, ctx.tokenOut, s.manifestOut); + ManifestCore memory manifestOut = policyReg.manifestOf(ctx.tokenOut); + RecipeBinding[] memory bindingsOut = policyReg.recipeBindingsOf(ctx.tokenOut); + allowedVenueTypes &= uint256(manifestOut.supportedEngines); + policyVersion = _max64(policyVersion, policyReg.manifestVersionOf(ctx.tokenOut)); + policyId = _accumulatePolicyId(policyId, ctx.tokenOut, manifestOut, bindingsOut); + _evaluateBindings(ctx, ctx.tokenOut, manifestOut, bindingsOut, bindingOffset, state); } - s.elements = _newAccumulator(s.elementCapacity); + return _buildDecision( + ctx, policyId, policyVersion, allowedVenueTypes, state.allowed, state.reasonCode, state.flagsBitmap + ); + } - if (statusIn == PolicyStatus.ACTIVE) { - _appendApplicableElements(ctx, ctx.tokenIn, s.manifestIn, s.elements); - } - if (statusOut == PolicyStatus.ACTIVE) { - _appendApplicableElements(ctx, ctx.tokenOut, s.manifestOut, s.elements); + function _evaluateBindings( + ComplianceContext calldata ctx, + address token, + ManifestCore memory manifest, + RecipeBinding[] memory bindings, + uint256 bindingOffset, + EvaluationState memory state + ) internal view { + if (bindings.length == 0) revert Errors.InvalidRecipeBinding(); + if (bindings.length > MAX_RECIPE_BINDINGS) { + revert Errors.TooManyRecipeBindings(bindings.length, MAX_RECIPE_BINDINGS); } + PathState memory paths; + paths.ids = new uint16[](bindings.length); + paths.passed = new bool[](bindings.length); + paths.reasonCodes = new bytes32[](bindings.length); + paths.priorities = new uint8[](bindings.length); + + bytes memory recipeContext = abi.encode(manifest.factsPacked, ctx); + for (uint256 i = 0; i < bindings.length; i++) { + RecipeBinding memory binding = bindings[i]; + (bool applicable, bool passed, bytes32 reasonCode) = _evaluateRecipe(ctx, token, binding, recipeContext); + + if (binding.mode == RecipeBindingMode.FLAG_ONLY) { + if (applicable && !passed) state.flagsBitmap |= uint256(1) << (bindingOffset + i); + continue; + } + if (binding.mode == RecipeBindingMode.REQUIRED_BLOCKING) { + if (applicable && !passed) _selectFailure(state, reasonCode, binding.priority); + continue; + } - (bool allowed, bytes32 reasonCode) = _runChecks(ctx, s.elements); + uint256 pathIndex = _pathIndex(paths, binding.pathGroupId); + if (applicable && passed) paths.passed[pathIndex] = true; + if (applicable && !passed) { + (paths.reasonCodes[pathIndex], paths.priorities[pathIndex]) = _preferredFailure( + paths.reasonCodes[pathIndex], paths.priorities[pathIndex], reasonCode, binding.priority + ); + } + } - return _buildDecision(ctx, s.policyId, s.policyVersion, s.allowedVenueTypes, allowed, reasonCode); + for (uint256 i = 0; i < paths.count; i++) { + if (paths.passed[i]) continue; + bytes32 reasonCode = paths.reasonCodes[i]; + if (reasonCode == bytes32(0)) reasonCode = ReasonCodes.encode(0, bytes32("PATH"), paths.ids[i]); + _selectFailure(state, reasonCode, paths.priorities[i]); + } } - /// @dev Resolve recipes → union/dedup their required elements. coverageScope - /// subtraction omitted in skeleton (no bit→elementId map). - function _appendApplicableElements( + function _evaluateRecipe( ComplianceContext calldata ctx, address token, - ManifestCore memory m, - ElementAccumulator memory elements - ) internal view { - bytes memory recipeContext = abi.encode(m.factsPacked, ctx); - uint16[2] memory candidates = [m.issuanceRecipeId, m.fundRecipeId]; - for (uint256 c = 0; c < candidates.length; c++) { - uint16 rid = candidates[c]; - if (rid == 0) { - if (c == 0) revert Errors.RecipeNotRegistered(rid); - continue; // fundRecipeId 0 = absent - } - address recipeAddr = recipeReg.recipeOf(rid); - if (recipeAddr == address(0)) revert Errors.RecipeNotRegistered(rid); - _appendRecipeElements(IRecipe(recipeAddr), recipeContext, token, rid, elements); + RecipeBinding memory binding, + bytes memory recipeContext + ) internal view returns (bool applicable, bool passed, bytes32 reasonCode) { + address recipeAddress = recipeReg.recipeOf(binding.recipeId); + if (recipeAddress == address(0)) revert Errors.RecipeNotRegistered(binding.recipeId); + IRecipe recipe = IRecipe(recipeAddress); + uint16 actualVersion = recipe.version(); + if (actualVersion != binding.recipeVersion) { + revert Errors.RecipeVersionMismatch(binding.recipeId, binding.recipeVersion, actualVersion); } + applicable = recipe.isApplicable(recipeContext); + if (!applicable) return (false, true, bytes32(0)); + + return _checkRequiredElements(ctx, token, binding.recipeId, recipe.requiredElements()); } - function _appendRecipeElements( - IRecipe recipe, - bytes memory recipeContext, + function _checkRequiredElements( + ComplianceContext calldata ctx, address token, - uint16 rid, - ElementAccumulator memory elements - ) internal view { - if (!recipe.isApplicable(recipeContext)) return; - bytes32[] memory required = recipe.requiredElements(); + uint16 recipeId, + bytes32[] memory required + ) private view returns (bool applicable, bool passed, bytes32 reasonCode) { + if (required.length > MAX_ELEMENTS_PER_RECIPE) { + revert Errors.TooManyRecipeElements(recipeId, required.length, MAX_ELEMENTS_PER_RECIPE); + } + bytes memory elementContext = abi.encode(ctx); + uint256 rwaAmount = token == ctx.tokenOut ? ctx.amountOut : ctx.amountIn; for (uint256 i = 0; i < required.length; i++) { - bytes32 elementId = required[i]; - if (!_seenForToken(elements, elementId, token)) { - elements.ids[elements.count] = elementId; - elements.tokens[elements.count] = token; - elements.contributingRecipeIds[elements.count] = rid; - elements.count++; - } + address element = elementReg.elementOf(required[i]); + if (element == address(0)) revert Errors.ElementNotRegistered(required[i]); + (bool elementPassed,) = + IComplianceElement(element).check(ctx.buyer, ctx.seller, token, rwaAmount, elementContext); + if (!elementPassed) return (true, false, ReasonCodes.encode(recipeId, required[i], 1)); } + return (true, true, bytes32(0)); } - /// @dev Cumulative AND across every unique element. First failure stops. - function _runChecks(ComplianceContext calldata ctx, ElementAccumulator memory elements) - internal - view - returns (bool allowed, bytes32 reasonCode) - { - bytes memory elementContext = abi.encode(ctx); - for (uint256 i = 0; i < elements.count; i++) { - address token = elements.tokens[i]; - // RWA-side amount: amountOut when the regulated token is tokenOut, - // else amountIn. This is the amount of the regulated asset moving. - uint256 rwaAmount = token == ctx.tokenOut ? ctx.amountOut : ctx.amountIn; - address el = elementReg.elementOf(elements.ids[i]); - if (el == address(0)) revert Errors.ElementNotRegistered(elements.ids[i]); - (bool passed,) = IComplianceElement(el).check(ctx.buyer, ctx.seller, token, rwaAmount, elementContext); - if (!passed) { - return (false, ReasonCodes.encode(elements.contributingRecipeIds[i], elements.ids[i], 1)); - } + function _pathIndex(PathState memory paths, uint16 pathGroupId) private pure returns (uint256) { + for (uint256 i = 0; i < paths.count; i++) { + if (paths.ids[i] == pathGroupId) return i; } - return (true, bytes32(0)); + uint256 index = paths.count; + paths.ids[index] = pathGroupId; + paths.count++; + return index; + } + + function _selectFailure(EvaluationState memory state, bytes32 reasonCode, uint8 priority) private pure { + state.allowed = false; + (state.reasonCode, state.reasonPriority) = + _preferredFailure(state.reasonCode, state.reasonPriority, reasonCode, priority); + } + + function _preferredFailure(bytes32 current, uint8 currentPriority, bytes32 candidate, uint8 candidatePriority) + private + pure + returns (bytes32, uint8) + { + if ( + current == bytes32(0) || candidatePriority > currentPriority + || (candidatePriority == currentPriority && uint256(candidate) < uint256(current)) + ) return (candidate, candidatePriority); + return (current, currentPriority); } function _buildDecision( @@ -226,41 +250,20 @@ contract ComplianceEngine is IComplianceEngine, Governed { uint64 policyVersion, uint256 allowedVenueTypes, bool allowed, - bytes32 reasonCode + bytes32 reasonCode, + uint256 flagsBitmap ) internal view returns (ComplianceDecision memory d) { d.allowed = allowed; - // policyId derivation remains a SKELETON PLACEHOLDER, but it now binds - // every ACTIVE side included in this pair-level decision rather than - // choosing only one side. A real implementation still needs the full - // versioned policy-set derivation. d.policyId = policyId; d.policyVersion = policyVersion; d.validUntil = uint64(block.timestamp + 1 days); - d.maxAmount = type(uint256).max; // skeleton: no quantitative cap - // Map supported-engine bits → VenueType bits. Skeleton 1:1 mapping: - // engine bit i corresponds to VenueType bit i (AMM=0, ORDER_BOOK=1, RFQ=2). + d.maxAmount = type(uint256).max; d.allowedVenueTypes = allowedVenueTypes; - d.allowedVenuesHash = bytes32(0); // 0 = any registered venue (skeleton) d.reasonCode = reasonCode; - d.reliedClaims = bytes32(0); // mock - // decisionHash is a FORWARD-LOOKING SEAM, not the live replay guard. The - // router calls engine.evaluate(ctx) fresh on every execute and never - // stores or verifies a decision, so REUSE IS STRUCTURALLY IMPOSSIBLE; the - // live replay guard is the per-caller `usedNonce` nonce gate in the - // router. decisionHash (and Errors.DecisionMismatch/DecisionExpired) exist - // for a future flow where a signed/pre-computed decision is passed in and - // verified against a recompute. Kept intentionally; do not remove. + d.flagsBitmap = flagsBitmap; d.decisionHash = _hash(ctx, d); } - /// @dev Compute decisionHash from the assembled decision. Isolated to keep - /// stack depth low (avoids spilling many locals at the call site). - /// NOTE: the hash binds INPUTS ONLY — the context plus the decision's - /// parameters (maxAmount, allowedVenueTypes, allowedVenuesHash, - /// policyVersion, validUntil). It deliberately does NOT cover the - /// outcome (allowed / reasonCode). It is a context-replay guard, not an - /// attestation of the verdict: the same inputs must hash the same way - /// regardless of whether the trade was allowed or rejected. function _hash(ComplianceContext calldata ctx, ComplianceDecision memory d) private pure returns (bytes32) { return DecisionHashLib.compute( ctx, d.maxAmount, d.allowedVenueTypes, d.allowedVenuesHash, d.policyVersion, d.validUntil @@ -269,14 +272,9 @@ contract ComplianceEngine is IComplianceEngine, Governed { function _passThrough(ComplianceContext calldata ctx) internal view returns (ComplianceDecision memory d) { d.allowed = true; - d.policyId = bytes32(0); - d.policyVersion = 0; d.validUntil = uint64(block.timestamp + 1 days); d.maxAmount = type(uint256).max; - d.allowedVenueTypes = type(uint256).max; // permissive default for unregulated - d.allowedVenuesHash = bytes32(0); - d.reasonCode = bytes32(0); - d.reliedClaims = bytes32(0); + d.allowedVenueTypes = type(uint256).max; d.decisionHash = _hash(ctx, d); } @@ -285,89 +283,159 @@ contract ComplianceEngine is IComplianceEngine, Governed { view returns (ComplianceDecision memory d) { - d.allowed = false; d.reasonCode = ReasonCodes.encode(0, bytes32("POLICY"), uint32(status)); d.validUntil = uint64(block.timestamp + 1 days); - d.maxAmount = 0; - d.allowedVenueTypes = 0; - d.allowedVenuesHash = bytes32(0); - d.reliedClaims = bytes32(0); d.decisionHash = _hash(ctx, d); } - /// @dev commit: post-trade hook. Recompute applicable element set; for each - /// STATEFUL element call onTransfer(seller, buyer, rwaAmount). - /// onlyRouter: this is the authenticated runtime-counter write path - /// (spec §6). Only the wired router may record post-trade state; an - /// EOA/operator cannot forge surveillance counters by calling commit. function commit(ComplianceContext calldata ctx) external override onlyRouter { - // STATEFUL post-trade hooks run for every ACTIVE regulated side; a - // non-ACTIVE side (SUSPENDED/UNKNOWN/PROPOSED/RETIRED) or both-UNREGULATED - // has nothing to commit. NO mirror of evaluate's default-deny gate is - // needed here: `commit` is reachable ONLY after the router's own - // `engine.evaluate(ctx)` returned allowed (it reverts ComplianceRejected - // otherwise), so any pair that would fail the allowlist never reaches - // this path. The final `statusIn/Out == ACTIVE` guards below are what - // actually decide which sides get committed. PolicyStatus statusIn = policyReg.statusOf(ctx.tokenIn); PolicyStatus statusOut = policyReg.statusOf(ctx.tokenOut); - if (statusIn == PolicyStatus.SUSPENDED || statusOut == PolicyStatus.SUSPENDED) return; - if (statusIn == PolicyStatus.UNKNOWN || statusOut == PolicyStatus.UNKNOWN) return; - if (statusIn != PolicyStatus.ACTIVE && statusOut != PolicyStatus.ACTIVE) { - return; + if (!_isPermitted(statusIn) || !_isPermitted(statusOut)) return; + + ElementAccumulator memory elements = _newAccumulator(); + if (statusIn == PolicyStatus.ACTIVE) _collectCommitElements(ctx, ctx.tokenIn, elements); + if (statusOut == PolicyStatus.ACTIVE) _collectCommitElements(ctx, ctx.tokenOut, elements); + + for (uint256 i = 0; i < elements.count; i++) { + address element = elementReg.elementOf(elements.ids[i]); + if (IComplianceElement(element).elementMetadata().statefulness == Statefulness.STATEFUL) { + uint256 rwaAmount = elements.tokens[i] == ctx.tokenOut ? ctx.amountOut : ctx.amountIn; + IStatefulElement(element).onTransfer(ctx.seller, ctx.buyer, rwaAmount); + } } + } - if (statusIn == PolicyStatus.ACTIVE) { - _commitActiveSide(ctx, ctx.tokenIn, policyReg.manifestOf(ctx.tokenIn)); + function _collectCommitElements(ComplianceContext calldata ctx, address token, ElementAccumulator memory elements) + internal + view + { + ManifestCore memory manifest = policyReg.manifestOf(token); + RecipeBinding[] memory bindings = policyReg.recipeBindingsOf(token); + if (bindings.length == 0) revert Errors.InvalidRecipeBinding(); + if (bindings.length > MAX_RECIPE_BINDINGS) { + revert Errors.TooManyRecipeBindings(bindings.length, MAX_RECIPE_BINDINGS); } - if (statusOut == PolicyStatus.ACTIVE) { - _commitActiveSide(ctx, ctx.tokenOut, policyReg.manifestOf(ctx.tokenOut)); + bytes memory recipeContext = abi.encode(manifest.factsPacked, ctx); + CommitPathState memory paths; + paths.ids = new uint16[](bindings.length); + paths.selected = new uint256[](bindings.length); + paths.priorities = new uint8[](bindings.length); + + for (uint256 i = 0; i < bindings.length; i++) { + RecipeBinding memory binding = bindings[i]; + IRecipe recipe = _validatedRecipe(binding); + if (!recipe.isApplicable(recipeContext)) continue; + + if (binding.mode == RecipeBindingMode.PATH_OPTION) { + (, bool passed,) = _checkRequiredElements(ctx, token, binding.recipeId, recipe.requiredElements()); + if (passed) _selectCommitPath(paths, bindings, i); + continue; + } + + // FLAG_ONLY is observational by contract: neither its pre-trade + // verdict nor a stateful post-trade hook may roll back settlement. + // Non-blocking observations must use the emitted flags/event stream + // rather than the trade-critical commit path. + if (binding.mode == RecipeBindingMode.FLAG_ONLY) continue; + + _appendRecipeElements(elements, token, binding.recipeId, recipe.requiredElements()); + } + + for (uint256 i = 0; i < paths.count; i++) { + uint256 selected = paths.selected[i]; + // A successful blocking evaluation guarantees one passing option + // for every applicable path group. Keep commit fail-closed if that + // invariant is ever broken by an incompatible caller or upgrade. + if (selected == 0) revert Errors.InvalidRecipeBinding(); + IRecipe recipe = _validatedRecipe(bindings[selected - 1]); + _appendRecipeElements(elements, token, bindings[selected - 1].recipeId, recipe.requiredElements()); } } - function _commitActiveSide(ComplianceContext calldata ctx, address token, ManifestCore memory m) internal { - uint256 rwaAmount = token == ctx.tokenOut ? ctx.amountOut : ctx.amountIn; + function _validatedRecipe(RecipeBinding memory binding) private view returns (IRecipe recipe) { + address recipeAddress = recipeReg.recipeOf(binding.recipeId); + if (recipeAddress == address(0)) revert Errors.RecipeNotRegistered(binding.recipeId); + recipe = IRecipe(recipeAddress); + uint16 actualVersion = recipe.version(); + if (actualVersion != binding.recipeVersion) { + revert Errors.RecipeVersionMismatch(binding.recipeId, binding.recipeVersion, actualVersion); + } + } - uint256 cap = _maxElements(m); - ElementAccumulator memory elements = _newAccumulator(cap); - _appendApplicableElements(ctx, token, m, elements); + function _selectCommitPath(CommitPathState memory paths, RecipeBinding[] memory bindings, uint256 candidateIndex) + private + pure + { + RecipeBinding memory candidate = bindings[candidateIndex]; + uint256 pathIndex = _commitPathIndex(paths, candidate.pathGroupId); + uint256 selected = paths.selected[pathIndex]; + if ( + selected == 0 || candidate.priority > paths.priorities[pathIndex] + || (candidate.priority == paths.priorities[pathIndex] + && candidate.recipeId < bindings[selected - 1].recipeId) + ) { + paths.selected[pathIndex] = candidateIndex + 1; + paths.priorities[pathIndex] = candidate.priority; + } + } - for (uint256 i = 0; i < elements.count; i++) { - address el = elementReg.elementOf(elements.ids[i]); - if (el == address(0)) revert Errors.ElementNotRegistered(elements.ids[i]); - if (IComplianceElement(el).elementMetadata().statefulness == Statefulness.STATEFUL) { - IStatefulElement(el).onTransfer(ctx.seller, ctx.buyer, rwaAmount); - } + function _commitPathIndex(CommitPathState memory paths, uint16 pathGroupId) private pure returns (uint256) { + for (uint256 i = 0; i < paths.count; i++) { + if (paths.ids[i] == pathGroupId) return i; } + uint256 index = paths.count; + paths.ids[index] = pathGroupId; + paths.count++; + return index; } - // ---- helpers ---- + function _appendRecipeElements( + ElementAccumulator memory elements, + address token, + uint16 recipeId, + bytes32[] memory required + ) private pure { + if (required.length > MAX_ELEMENTS_PER_RECIPE) { + revert Errors.TooManyRecipeElements(recipeId, required.length, MAX_ELEMENTS_PER_RECIPE); + } + for (uint256 i = 0; i < required.length; i++) { + if (!_seen(elements, required[i], token)) { + elements.ids[elements.count] = required[i]; + elements.tokens[elements.count] = token; + elements.count++; + } + } + } - function _newAccumulator(uint256 capacity) private pure returns (ElementAccumulator memory elements) { + function _newAccumulator() private pure returns (ElementAccumulator memory elements) { + uint256 capacity = 2 * MAX_RECIPE_BINDINGS * MAX_ELEMENTS_PER_RECIPE; elements.ids = new bytes32[](capacity); elements.tokens = new address[](capacity); - elements.contributingRecipeIds = new uint16[](capacity); } - function _seenForToken(ElementAccumulator memory elements, bytes32 id, address token) private pure returns (bool) { + function _seen(ElementAccumulator memory elements, bytes32 id, address token) private pure returns (bool) { for (uint256 i = 0; i < elements.count; i++) { if (elements.ids[i] == id && elements.tokens[i] == token) return true; } return false; } - function _accumulatePolicyId(bytes32 acc, address token, ManifestCore memory m) private pure returns (bytes32) { + function _accumulatePolicyId( + bytes32 acc, + address token, + ManifestCore memory manifest, + RecipeBinding[] memory bindings + ) private pure returns (bytes32) { return keccak256( abi.encode( acc, token, - m.issuanceRecipeId, - m.issuanceRecipeVersion, - m.fundRecipeId, - m.supportedEngines, - m.factsPacked, - m.coverageScope, - m.fullManifestHash + keccak256(abi.encode(bindings)), + manifest.supportedEngines, + manifest.factsPacked, + manifest.coverageScope, + manifest.fullManifestHash ) ); } @@ -375,17 +443,4 @@ contract ComplianceEngine is IComplianceEngine, Governed { function _max64(uint64 a, uint64 b) private pure returns (uint64) { return a >= b ? a : b; } - - /// @dev Upper bound on distinct elements: sum of required-element counts of - /// all candidate recipes. We don't know counts statically, so use a - /// generous fixed cap; dedup keeps the live count correct. - /// FAIL-CLOSED INVARIANT: if the union of required elements ever exceeds - /// this cap, `elementIds[count] = ...` in `_applicableElements` reverts - /// with an array out-of-bounds panic. That is intentional — it never - /// silently drops a required check. A future maintainer MUST NOT add a - /// `count < cap` guard around the write: truncating the element set - /// would let a trade skip a required compliance check (fail-open). - function _maxElements(ManifestCore memory) private pure returns (uint256) { - return 32; - } } diff --git a/src/compliance/elements/Lockup.sol b/src/compliance/elements/Lockup.sol index f905ffa..3cebd5d 100644 --- a/src/compliance/elements/Lockup.sol +++ b/src/compliance/elements/Lockup.sol @@ -13,8 +13,9 @@ import { } from "../../types/ComplianceTypes.sol"; import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; -/// @dev C-01-v1 Rule 144 lockup (mock). Reads acquisition time from an INJECTED -/// source (CR-3 seam) — this contract does NOT maintain an acquisition registry. +/// @dev C-01-v1 Rule 144 lockup. Reads a conservative, expiring acquisition +/// snapshot from an injected provider-neutral source. Per-lot/PII data stays +/// off-chain and this contract fails closed on missing or broken lineage. contract Lockup is BaseElement { bytes32 internal constant ELEMENT_ID = "C-01-v1"; @@ -42,8 +43,17 @@ contract Lockup is BaseElement { override returns (bool passed, bytes32 reasonCode) { - uint64 acquired = acquisitionSource.acquiredAt(user, asset); - passed = acquired != 0 && block.timestamp >= uint256(acquired) + lockupSeconds; - reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + IAcquisitionSource.AcquisitionSnapshot memory snapshot = acquisitionSource.acquisitionOf(user, asset); + if (snapshot.status == IAcquisitionSource.AcquisitionStatus.MISSING) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 1)); + } + if (snapshot.status == IAcquisitionSource.AcquisitionStatus.LINEAGE_BROKEN) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 2)); + } + if (snapshot.expiresAt == 0 || block.timestamp > snapshot.expiresAt) { + return (false, ReasonCodes.encode(0, ELEMENT_ID, 3)); + } + passed = snapshot.clockStart != 0 && block.timestamp >= uint256(snapshot.clockStart) + lockupSeconds; + reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 4); } } diff --git a/src/demo/BuidlLikeDemoAsset.sol b/src/demo/BuidlLikeDemoAsset.sol index 6bc48db..0d66f3a 100644 --- a/src/demo/BuidlLikeDemoAsset.sol +++ b/src/demo/BuidlLikeDemoAsset.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; -import {ManifestCore, PolicyStatus} from "../types/ComplianceTypes.sol"; +import {ManifestCore, PolicyStatus, RecipeBinding, RecipeBindingMode} from "../types/ComplianceTypes.sol"; /// @title BuidlLikeDemoAsset /// @notice Giwa MVP demo profile for a local BUIDL-like ERC-3643 asset. @@ -18,7 +18,7 @@ library BuidlLikeDemoAsset { string internal constant TOKEN_SYMBOL = "bBUIDL"; uint16 internal constant ISSUANCE_RECIPE_ID = 1; // Reg D 506(c) - uint16 internal constant ISSUANCE_RECIPE_VERSION = 1; + uint16 internal constant ISSUANCE_RECIPE_VERSION = 2; uint16 internal constant FUND_RECIPE_ID = 3; // BUIDL-like ICA 3(c)(7) + minimum investment uint16 internal constant FUND_RECIPE_VERSION = 1; @@ -33,9 +33,6 @@ library BuidlLikeDemoAsset { function manifest(uint8 supportedEngines) internal pure returns (ManifestCore memory m) { m.status = PolicyStatus.ACTIVE; - m.issuanceRecipeId = ISSUANCE_RECIPE_ID; - m.issuanceRecipeVersion = ISSUANCE_RECIPE_VERSION; - m.fundRecipeId = FUND_RECIPE_ID; m.supportedEngines = supportedEngines; m.factsPacked = FACT_FUND_APPLICABLE; m.fullManifestHash = keccak256( @@ -48,4 +45,11 @@ library BuidlLikeDemoAsset { ) ); } + + function recipeBindings() internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](2); + bindings[0] = + RecipeBinding(ISSUANCE_RECIPE_ID, ISSUANCE_RECIPE_VERSION, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(FUND_RECIPE_ID, FUND_RECIPE_VERSION, RecipeBindingMode.REQUIRED_BLOCKING, 0, 90); + } } diff --git a/src/execution/ExecutionRouter.sol b/src/execution/ExecutionRouter.sol index 6931891..7051305 100644 --- a/src/execution/ExecutionRouter.sol +++ b/src/execution/ExecutionRouter.sol @@ -52,7 +52,14 @@ contract ExecutionRouter is IExecutionRouter, Governed, ReentrancyGuard { revert Errors.NotAuthorized(); } - // 2a. nonce replay protection (per authenticated initiator/caller) + // 2a. central production pause gates. These run before nonce consumption + // so a closed control plane does not burn user replay slots. + if (operatorReg.isGlobalPaused()) revert Errors.GlobalPaused(); + if (operatorReg.isAssetSuspended(req.context.tokenIn)) revert Errors.TokenInPaused(); + if (operatorReg.isAssetSuspended(req.context.tokenOut)) revert Errors.TokenOutPaused(); + if (operatorReg.isVenueSuspended(req.context.venue)) revert Errors.VenueSuspended(); + + // 2b. nonce replay protection (per authenticated initiator/caller) if (usedNonce[msg.sender][req.nonce]) revert Errors.NonceUsed(); usedNonce[msg.sender][req.nonce] = true; @@ -64,6 +71,8 @@ contract ExecutionRouter is IExecutionRouter, Governed, ReentrancyGuard { // a future flow where a signed/pre-computed decision is passed in and // verified here, not the current replay defense. ComplianceDecision memory d = engine.evaluate(req.context); + emit Events.ComplianceEvaluated(d.decisionHash, d.allowed, d.reasonCode); + if (d.flagsBitmap != 0) emit Events.ComplianceFlags(d.decisionHash, d.flagsBitmap); if (!d.allowed) revert Errors.ComplianceRejected(d.reasonCode); // 4. amount bound. NOTE (open decision): this bounds the INPUT (quote-side) @@ -74,32 +83,29 @@ contract ExecutionRouter is IExecutionRouter, Governed, ReentrancyGuard { // notional — and align this check with that choice. if (req.context.amountIn > d.maxAmount) revert Errors.MaxAmountExceeded(); - // 5. operator venue-suspension kill switch - if (operatorReg.isVenueSuspended(req.context.venue)) revert Errors.VenueSuspended(); - - // 6. venue policy binding (type mask + venues-hash) + // 5. venue policy binding (type mask + venues-hash) if (!selector.validate(req.context.venue, req.context.venueType, d)) revert Errors.VenueNotAllowed(); - // 7. resolve adapter + // 6. resolve adapter VenueConfig memory cfg = venueReg.venueOf(req.context.venue); if (!cfg.active || cfg.adapter == address(0)) revert Errors.AdapterNotRegistered(); - // 7a. bind the caller-supplied venue type to the registry's stored config. + // 6a. bind the caller-supplied venue type to the registry's stored config. // Step 6 validated req.context.venueType against the decision's type mask; // without this check a caller could misreport venueType and route a venue // of a disallowed type through an allowed-type bit. if (cfg.venueType != req.context.venueType) revert Errors.VenueTypeMismatch(); - // 8. dispatch to adapter (performs the swap; non-custodial) + // 7. dispatch to adapter (performs the swap; non-custodial) ExecutionResult memory r = IExecutionAdapter(cfg.adapter).execute(req, d); - // 8a. slippage bound — the realized output must meet the caller's minimum. + // 7a. slippage bound — the realized output must meet the caller's minimum. if (r.amountOut < req.amountOutMin) revert Errors.SlippageExceeded(); - // 9. post-trade commit hook (stateful element updates) + // 8. post-trade commit hook (stateful element updates) engine.commit(req.context); - // 10. emit + return + // 9. emit + return emit Events.Executed(r.executionId, req.context.venue, r.amountOut); return r; } diff --git a/src/execution/adapters/amm/UniswapV3Adapter.sol b/src/execution/adapters/amm/UniswapV3Adapter.sol index 807289c..7479d2b 100644 --- a/src/execution/adapters/amm/UniswapV3Adapter.sol +++ b/src/execution/adapters/amm/UniswapV3Adapter.sol @@ -59,20 +59,25 @@ contract UniswapV3Adapter is IAMMAdapter, Governed { require(registeredPool[pool], "pool not registered"); (bool zeroForOne, uint160 sqrtPriceLimitX96) = _decodeVenueData(req.venueData); + IPool targetPool = IPool(pool); + address expectedTokenIn = address(zeroForOne ? targetPool.token0() : targetPool.token1()); + address expectedTokenOut = address(zeroForOne ? targetPool.token1() : targetPool.token0()); + if (req.context.tokenIn != expectedTokenIn || req.context.tokenOut != expectedTokenOut) { + revert Errors.AMMPoolTokenMismatch(); + } // Encode payer + tokenIn so the callback can pull funds from the buyer. bytes memory cb = abi.encode(req.context.buyer, req.context.tokenIn); uint256 balBefore = IERC20(req.context.tokenOut).balanceOf(req.context.buyer); - IPool(pool) - .swap( - req.context.buyer, // recipient of tokenOut - zeroForOne, - int256(req.context.amountIn), // exact input - sqrtPriceLimitX96, - cb - ); + targetPool.swap( + req.context.buyer, // recipient of tokenOut + zeroForOne, + int256(req.context.amountIn), // exact input + sqrtPriceLimitX96, + cb + ); uint256 amountOut = IERC20(req.context.tokenOut).balanceOf(req.context.buyer) - balBefore; @@ -86,8 +91,18 @@ contract UniswapV3Adapter is IAMMAdapter, Governed { (address payer, address tokenIn) = abi.decode(data, (address, address)); - // The owed amount is the positive delta (pool is owed tokenIn). - uint256 amountOwed = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta); + // Exactly one positive delta identifies the token owed to the pool. + // Bind that delta to the registered pool's canonical token ordering so + // malformed callback data cannot redirect transferFrom to another asset. + uint256 amountOwed; + IPool pool = IPool(msg.sender); + if (amount0Delta > 0 && amount1Delta <= 0 && tokenIn == address(pool.token0())) { + amountOwed = uint256(amount0Delta); + } else if (amount1Delta > 0 && amount0Delta <= 0 && tokenIn == address(pool.token1())) { + amountOwed = uint256(amount1Delta); + } else { + revert Errors.AMMPoolTokenMismatch(); + } IERC20(tokenIn).safeTransferFrom(payer, msg.sender, amountOwed); } diff --git a/src/factory/CornerStoreFactory.sol b/src/factory/CornerStoreFactory.sol index ee9f4a2..94c89f8 100644 --- a/src/factory/CornerStoreFactory.sol +++ b/src/factory/CornerStoreFactory.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.17; import {Governed} from "../auth/Governed.sol"; import {ITokenPolicyRegistry} from "../interfaces/compliance/ITokenPolicyRegistry.sol"; import {IVenueRegistry} from "../interfaces/execution/IVenueRegistry.sol"; -import {ManifestCore} from "../types/ComplianceTypes.sol"; +import {ManifestCore, RecipeBinding} from "../types/ComplianceTypes.sol"; import {VenueConfig} from "../types/VenueTypes.sol"; /// @title CornerStoreFactory @@ -49,15 +49,43 @@ contract CornerStoreFactory is Governed { function registerRWAToken( address token, ManifestCore calldata manifest, + RecipeBinding[] calldata bindings, address venue, VenueConfig calldata venueCfg ) external onlyOperator { - tokenPolicyRegistry.registerManifest(token, manifest); + tokenPolicyRegistry.registerManifest(token, manifest, bindings); tokenPolicyRegistry.approveManifest(token); venueRegistry.registerVenue(venue, venueCfg); emit RWATokenRegistered(token, venue); } + /// @notice Schedule a delayed manifest reopening through the registry owner. + /// @dev The factory owns the registry after deployment, while this factory's + /// owner is the external governance account (a Safe in production). + function scheduleManifestResume(address token, bytes32 reasonCode) external onlyOwner { + tokenPolicyRegistry.scheduleManifestResume(token, reasonCode); + } + + /// @notice Cancel a pending manifest reopening through governance. + function cancelManifestResume(address token) external onlyOwner { + tokenPolicyRegistry.cancelManifestResume(token); + } + + /// @notice Schedule a delayed semantic manifest update through governance. + function scheduleManifestUpdate( + address token, + ManifestCore calldata manifest, + RecipeBinding[] calldata bindings, + bytes32 reasonCode + ) external onlyOwner { + tokenPolicyRegistry.scheduleManifestUpdate(token, manifest, bindings, reasonCode); + } + + /// @notice Cancel a pending semantic manifest update through governance. + function cancelManifestUpdate(address token) external onlyOwner { + tokenPolicyRegistry.cancelManifestUpdate(token); + } + /// @notice Deterministic pool-address derivation STUB. /// @dev This is NOT the real Uniswap V3 pool address: it does not use the /// canonical factory init-code-hash or the v3 pool salt layout. It is a diff --git a/src/interfaces/compliance/IAcquisitionSource.sol b/src/interfaces/compliance/IAcquisitionSource.sol index 859bc72..ac60265 100644 --- a/src/interfaces/compliance/IAcquisitionSource.sol +++ b/src/interfaces/compliance/IAcquisitionSource.sol @@ -1,8 +1,23 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; -/// @dev CR-3 seam: the real Rule 144 acquisition-time source is unresolved, so the -/// Lockup element reads acquisition time through this injected interface only. +/// @notice Provider-neutral on-chain snapshot consumed by the Rule 144 Lockup. +/// @dev The Transfer Agent/provider remains off-chain. An approved adapter must +/// compile per-lot records and attest only the conservative clock snapshot. interface IAcquisitionSource { - function acquiredAt(address holder, address asset) external view returns (uint64); + enum AcquisitionStatus { + MISSING, + VALID, + LINEAGE_BROKEN + } + + struct AcquisitionSnapshot { + uint64 clockStart; + uint64 observedAt; + uint64 expiresAt; + bytes32 sourceRef; + AcquisitionStatus status; + } + + function acquisitionOf(address holder, address asset) external view returns (AcquisitionSnapshot memory); } diff --git a/src/interfaces/compliance/IOperatorRegistry.sol b/src/interfaces/compliance/IOperatorRegistry.sol index eb470c9..72fcd2a 100644 --- a/src/interfaces/compliance/IOperatorRegistry.sol +++ b/src/interfaces/compliance/IOperatorRegistry.sol @@ -2,7 +2,35 @@ pragma solidity 0.8.17; interface IOperatorRegistry { + function MIN_UNPAUSE_DELAY() external view returns (uint64); + + function setGlobalPaused(bool paused, bytes32 reasonCode) external; + + function scheduleGlobalUnpause(bytes32 reasonCode) external; + + function cancelGlobalUnpause() external; + + function executeGlobalUnpause() external; + + function setAssetSuspended(address token, bool suspended, bytes32 reasonCode) external; + + function scheduleAssetUnpause(address token, bytes32 reasonCode) external; + + function cancelAssetUnpause(address token) external; + + function executeAssetUnpause(address token) external; + function setVenueSuspended(address venue, bool suspended, bytes32 reasonCode) external; + function scheduleVenueUnpause(address venue, bytes32 reasonCode) external; + + function cancelVenueUnpause(address venue) external; + + function executeVenueUnpause(address venue) external; + + function isGlobalPaused() external view returns (bool); + + function isAssetSuspended(address token) external view returns (bool); + function isVenueSuspended(address venue) external view returns (bool); } diff --git a/src/interfaces/compliance/ITokenPolicyRegistry.sol b/src/interfaces/compliance/ITokenPolicyRegistry.sol index 6fc5358..2b06d9b 100644 --- a/src/interfaces/compliance/ITokenPolicyRegistry.sol +++ b/src/interfaces/compliance/ITokenPolicyRegistry.sol @@ -1,17 +1,34 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; -import {ManifestCore, PolicyStatus} from "../../types/ComplianceTypes.sol"; +import {ManifestCore, PolicyStatus, RecipeBinding} from "../../types/ComplianceTypes.sol"; // ITokenPolicyRegistry (Manifest store) interface ITokenPolicyRegistry { - function registerManifest(address token, ManifestCore calldata m) external; // -> PROPOSED + function MIN_MANIFEST_DELAY() external view returns (uint64); + + function registerManifest(address token, ManifestCore calldata m, RecipeBinding[] calldata bindings) external; // -> PROPOSED function approveManifest(address token) external; // PROPOSED -> ACTIVE function suspendManifest(address token, bytes32 reasonCode) external; // ACTIVE -> SUSPENDED - function resumeManifest(address token) external; // SUSPENDED -> ACTIVE + function scheduleManifestResume(address token, bytes32 reasonCode) external; // SUSPENDED -> pending ACTIVE + + function cancelManifestResume(address token) external; + + function resumeManifest(address token) external; // executes pending SUSPENDED -> ACTIVE after delay + + function scheduleManifestUpdate( + address token, + ManifestCore calldata m, + RecipeBinding[] calldata bindings, + bytes32 reasonCode + ) external; + + function cancelManifestUpdate(address token) external; + + function activateManifestUpdate(address token) external; function retireManifest(address token, bytes32 reasonCode) external; // ACTIVE/SUSPENDED -> RETIRED @@ -21,7 +38,25 @@ interface ITokenPolicyRegistry { function manifestOf(address token) external view returns (ManifestCore memory); + function recipeBindingsOf(address token) external view returns (RecipeBinding[] memory); + function statusOf(address token) external view returns (PolicyStatus); + function manifestVersionOf(address token) external view returns (uint64); + + function manifestHistoryHashOf(address token) external view returns (bytes32); + + function pendingManifestUpdateOf(address token) + external + view + returns ( + ManifestCore memory manifest, + RecipeBinding[] memory bindings, + uint64 effectiveTime, + bytes32 reasonCode + ); + + function pendingManifestResumeOf(address token) external view returns (uint64 effectiveTime, bytes32 reasonCode); + function setFact(address token, uint256 factsPacked) external; // strengthen-only } diff --git a/src/interfaces/execution/adapters/IPool.sol b/src/interfaces/execution/adapters/IPool.sol index 2a7f774..bf2558d 100644 --- a/src/interfaces/execution/adapters/IPool.sol +++ b/src/interfaces/execution/adapters/IPool.sol @@ -1,8 +1,14 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + // IPool — minimal mock-pool callback surface (uniswap v3 콜백 모방) interface IPool { + function token0() external view returns (IERC20); + + function token1() external view returns (IERC20); + function swap( address recipient, bool zeroForOne, diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 2dc0acb..c04bd11 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -5,6 +5,9 @@ library Errors { error NotAuthorized(); error PolicyNotActive(); // UNKNOWN/SUSPENDED error ComplianceRejected(bytes32 reasonCode); + error GlobalPaused(); + error TokenInPaused(); + error TokenOutPaused(); error VenueNotAllowed(); error VenueTypeMismatch(); error VenueSuspended(); @@ -15,6 +18,7 @@ library Errors { error DecisionMismatch(); // decisionHash != recomputed error MaxAmountExceeded(); error SlippageExceeded(); + error AMMPoolTokenMismatch(); error RFQInvalidSignature(); error RFQQuoteExpired(); error RFQQuoteUsed(); @@ -24,4 +28,15 @@ library Errors { error RecipeNotRegistered(uint16 recipeId); error LooseningForbidden(); // strengthen-only override error InvalidManifestTransition(); // illegal PolicyStatus lifecycle move + error TimelockNotReady(uint256 readyAt); + error PendingActionNotFound(); + error PendingActionExists(); + error InvalidManifestHash(); + error ZeroAddress(); + error InvalidAcquisitionSnapshot(); + error InvalidRecipeBinding(); + error TooManyRecipeBindings(uint256 supplied, uint256 maximum); + error TooManyRecipeElements(uint16 recipeId, uint256 supplied, uint256 maximum); + error DuplicateRecipeBinding(uint16 recipeId); + error RecipeVersionMismatch(uint16 recipeId, uint16 expected, uint16 actual); } diff --git a/src/libraries/Events.sol b/src/libraries/Events.sol index 6c02b47..ce9e525 100644 --- a/src/libraries/Events.sol +++ b/src/libraries/Events.sol @@ -4,13 +4,70 @@ pragma solidity 0.8.17; import "../types/ComplianceTypes.sol"; library Events { - event ManifestRegistered(address indexed token, uint16 issuanceRecipeId, address declaredBy); + event ManifestRegistered(address indexed token, bytes32 bindingsHash, address declaredBy); event ManifestStatusChanged(address indexed token, PolicyStatus status, bytes32 reasonCode); + event ManifestSemanticUpdateScheduled( + address indexed token, + uint64 oldVersion, + uint64 newVersion, + bytes32 oldManifestHash, + bytes32 newManifestHash, + bytes32 reasonCode, + uint64 effectiveTime + ); + event ManifestSemanticUpdateCancelled(address indexed token); + event ManifestSemanticUpdateActivated( + address indexed token, + uint64 oldVersion, + uint64 newVersion, + bytes32 oldManifestHash, + bytes32 newManifestHash, + bytes32 reasonCode, + uint64 effectiveTime + ); + event ManifestHistoryAppended( + address indexed token, + uint64 version, + PolicyStatus oldStatus, + PolicyStatus newStatus, + bytes32 oldManifestHash, + bytes32 newManifestHash, + bytes32 historyHash, + address actor, + bytes32 reasonCode, + bytes32 reasonHash, + uint64 effectiveTime + ); + event ManifestResumeScheduled(address indexed token, bytes32 reasonCode, uint64 effectiveTime); + event ManifestResumeCancelled(address indexed token); event ElementRegistered(bytes32 indexed elementId, address element); event RecipeRegistered(uint16 indexed recipeId, uint16 version, address recipe); event VenueRegistered(address indexed venue, VenueType venueType, address adapter); + event GlobalSuspended(bytes32 reasonCode); + event AssetSuspended(address indexed token, bytes32 reasonCode); event VenueSuspended(address indexed venue, bytes32 reasonCode); + event GlobalUnpauseScheduled(address indexed actor, bytes32 reasonCode, uint64 effectiveTime); + event AssetUnpauseScheduled(address indexed token, address indexed actor, bytes32 reasonCode, uint64 effectiveTime); + event VenueUnpauseScheduled(address indexed venue, address indexed actor, bytes32 reasonCode, uint64 effectiveTime); + event GlobalUnpauseCancelled(address indexed actor); + event AssetUnpauseCancelled(address indexed token, address indexed actor); + event VenueUnpauseCancelled(address indexed venue, address indexed actor); + event GlobalUnpaused(address indexed actor); + event AssetUnpaused(address indexed token, address indexed actor); + event VenueUnpaused(address indexed venue, address indexed actor); + event PauseActionRecorded( + bytes32 indexed scope, + address indexed target, + bool oldValue, + bool newValue, + address actor, + bytes32 reasonCode, + bytes32 reasonHash, + uint64 effectiveTime, + bytes32 historyHash + ); event ComplianceEvaluated(bytes32 indexed decisionHash, bool allowed, bytes32 reasonCode); + event ComplianceFlags(bytes32 indexed decisionHash, uint256 flagsBitmap); event Executed(bytes32 indexed executionId, address indexed venue, uint256 amountOut); event SurveillanceFlag(bytes32 indexed elementId, address indexed subject, bytes32 reasonCode); } diff --git a/src/registry/AttestedAcquisitionSource.sol b/src/registry/AttestedAcquisitionSource.sol new file mode 100644 index 0000000..3e68b51 --- /dev/null +++ b/src/registry/AttestedAcquisitionSource.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Governed} from "../auth/Governed.sol"; +import {IAcquisitionSource} from "../interfaces/compliance/IAcquisitionSource.sol"; +import {Errors} from "../libraries/Errors.sol"; + +/// @notice On-chain cache for provider/TA-compiled acquisition snapshots. +/// @dev It does not fetch or verify a specific provider API. Governance chooses +/// operators; those operators attest a PII-free source reference and expiry. +contract AttestedAcquisitionSource is IAcquisitionSource, Governed { + mapping(bytes32 => AcquisitionSnapshot) internal _snapshots; + + event AcquisitionSnapshotSet( + address indexed holder, + address indexed asset, + uint64 clockStart, + uint64 observedAt, + uint64 expiresAt, + bytes32 sourceRef, + AcquisitionStatus status + ); + event AcquisitionSnapshotCleared(address indexed holder, address indexed asset); + + function setSnapshot( + address holder, + address asset, + uint64 clockStart, + uint64 expiresAt, + bytes32 sourceRef, + AcquisitionStatus status + ) external onlyOperator { + if (holder == address(0) || asset == address(0)) revert Errors.ZeroAddress(); + if (status == AcquisitionStatus.MISSING) revert Errors.InvalidAcquisitionSnapshot(); + if (expiresAt <= block.timestamp || sourceRef == bytes32(0)) revert Errors.InvalidAcquisitionSnapshot(); + if (status == AcquisitionStatus.VALID && (clockStart == 0 || clockStart > block.timestamp)) { + revert Errors.InvalidAcquisitionSnapshot(); + } + if (status == AcquisitionStatus.LINEAGE_BROKEN && clockStart != 0) { + revert Errors.InvalidAcquisitionSnapshot(); + } + + uint64 observedAt = uint64(block.timestamp); + _snapshots[_key(holder, asset)] = AcquisitionSnapshot(clockStart, observedAt, expiresAt, sourceRef, status); + emit AcquisitionSnapshotSet(holder, asset, clockStart, observedAt, expiresAt, sourceRef, status); + } + + function clearSnapshot(address holder, address asset) external onlyOperator { + delete _snapshots[_key(holder, asset)]; + emit AcquisitionSnapshotCleared(holder, asset); + } + + function acquisitionOf(address holder, address asset) external view returns (AcquisitionSnapshot memory) { + return _snapshots[_key(holder, asset)]; + } + + function _key(address holder, address asset) internal pure returns (bytes32) { + return keccak256(abi.encode(holder, asset)); + } +} diff --git a/src/registry/OperatorRegistry.sol b/src/registry/OperatorRegistry.sol index 2c8db83..dcd0d6d 100644 --- a/src/registry/OperatorRegistry.sol +++ b/src/registry/OperatorRegistry.sol @@ -3,17 +3,192 @@ pragma solidity 0.8.17; import {Governed} from "../auth/Governed.sol"; import {IOperatorRegistry} from "../interfaces/compliance/IOperatorRegistry.sol"; +import {Errors} from "../libraries/Errors.sol"; import {Events} from "../libraries/Events.sol"; contract OperatorRegistry is IOperatorRegistry, Governed { - mapping(address => bool) internal _suspended; + uint64 public constant MIN_UNPAUSE_DELAY = 1 days; + bytes32 internal constant GLOBAL_SCOPE = "GLOBAL"; + bytes32 internal constant ASSET_SCOPE = "ASSET"; + bytes32 internal constant VENUE_SCOPE = "VENUE"; + + struct PendingUnpause { + uint64 effectiveTime; + bytes32 reasonCode; + } + + bool internal _globalPaused; + PendingUnpause internal _pendingGlobalUnpause; + mapping(address => bool) internal _assetSuspended; + mapping(address => PendingUnpause) internal _pendingAssetUnpause; + mapping(address => bool) internal _venueSuspended; + mapping(address => PendingUnpause) internal _pendingVenueUnpause; + bytes32 public pauseHistoryHash; + + function setGlobalPaused(bool paused, bytes32 reasonCode) external onlyOperator { + if (paused) { + bool oldValue = _globalPaused; + _globalPaused = true; + delete _pendingGlobalUnpause; + emit Events.GlobalSuspended(reasonCode); + _recordPause(GLOBAL_SCOPE, address(0), oldValue, true, reasonCode, bytes32(0), uint64(block.timestamp)); + return; + } + if (msg.sender != owner()) revert Errors.NotAuthorized(); + _executeGlobalUnpause(); + } + + function scheduleGlobalUnpause(bytes32 reasonCode) external onlyOwner { + if (!_globalPaused) revert Errors.InvalidManifestTransition(); + if (_pendingGlobalUnpause.effectiveTime != 0) revert Errors.PendingActionExists(); + _pendingGlobalUnpause = PendingUnpause(_readyTime(), reasonCode); + emit Events.GlobalUnpauseScheduled(msg.sender, reasonCode, _pendingGlobalUnpause.effectiveTime); + } + + function cancelGlobalUnpause() external onlyOwner { + if (_pendingGlobalUnpause.effectiveTime == 0) revert Errors.PendingActionNotFound(); + delete _pendingGlobalUnpause; + emit Events.GlobalUnpauseCancelled(msg.sender); + } + + function executeGlobalUnpause() external onlyOwner { + _executeGlobalUnpause(); + } + + function setAssetSuspended(address token, bool suspended, bytes32 reasonCode) external onlyOperator { + if (suspended) { + bool oldValue = _assetSuspended[token]; + _assetSuspended[token] = true; + delete _pendingAssetUnpause[token]; + emit Events.AssetSuspended(token, reasonCode); + _recordPause(ASSET_SCOPE, token, oldValue, true, reasonCode, bytes32(0), uint64(block.timestamp)); + return; + } + if (msg.sender != owner()) revert Errors.NotAuthorized(); + _executeAssetUnpause(token); + } + + function scheduleAssetUnpause(address token, bytes32 reasonCode) external onlyOwner { + if (!_assetSuspended[token]) revert Errors.InvalidManifestTransition(); + if (_pendingAssetUnpause[token].effectiveTime != 0) revert Errors.PendingActionExists(); + _pendingAssetUnpause[token] = PendingUnpause(_readyTime(), reasonCode); + emit Events.AssetUnpauseScheduled(token, msg.sender, reasonCode, _pendingAssetUnpause[token].effectiveTime); + } + + function cancelAssetUnpause(address token) external onlyOwner { + if (_pendingAssetUnpause[token].effectiveTime == 0) revert Errors.PendingActionNotFound(); + delete _pendingAssetUnpause[token]; + emit Events.AssetUnpauseCancelled(token, msg.sender); + } + + function executeAssetUnpause(address token) external onlyOwner { + _executeAssetUnpause(token); + } function setVenueSuspended(address venue, bool suspended, bytes32 reasonCode) external onlyOperator { - _suspended[venue] = suspended; - if (suspended) emit Events.VenueSuspended(venue, reasonCode); + if (suspended) { + bool oldValue = _venueSuspended[venue]; + _venueSuspended[venue] = true; + delete _pendingVenueUnpause[venue]; + emit Events.VenueSuspended(venue, reasonCode); + _recordPause(VENUE_SCOPE, venue, oldValue, true, reasonCode, bytes32(0), uint64(block.timestamp)); + return; + } + if (msg.sender != owner()) revert Errors.NotAuthorized(); + _executeVenueUnpause(venue); + } + + function scheduleVenueUnpause(address venue, bytes32 reasonCode) external onlyOwner { + if (!_venueSuspended[venue]) revert Errors.InvalidManifestTransition(); + if (_pendingVenueUnpause[venue].effectiveTime != 0) revert Errors.PendingActionExists(); + _pendingVenueUnpause[venue] = PendingUnpause(_readyTime(), reasonCode); + emit Events.VenueUnpauseScheduled(venue, msg.sender, reasonCode, _pendingVenueUnpause[venue].effectiveTime); + } + + function cancelVenueUnpause(address venue) external onlyOwner { + if (_pendingVenueUnpause[venue].effectiveTime == 0) revert Errors.PendingActionNotFound(); + delete _pendingVenueUnpause[venue]; + emit Events.VenueUnpauseCancelled(venue, msg.sender); + } + + function executeVenueUnpause(address venue) external onlyOwner { + _executeVenueUnpause(venue); + } + + function isGlobalPaused() external view returns (bool) { + return _globalPaused; + } + + function isAssetSuspended(address token) external view returns (bool) { + return _assetSuspended[token]; } function isVenueSuspended(address venue) external view returns (bool) { - return _suspended[venue]; + return _venueSuspended[venue]; + } + + function _executeGlobalUnpause() internal { + PendingUnpause memory pending = _pendingGlobalUnpause; + _requireReady(pending.effectiveTime); + _globalPaused = false; + delete _pendingGlobalUnpause; + emit Events.GlobalUnpaused(msg.sender); + _recordPause(GLOBAL_SCOPE, address(0), true, false, pending.reasonCode, bytes32(0), pending.effectiveTime); + } + + function _executeAssetUnpause(address token) internal { + PendingUnpause memory pending = _pendingAssetUnpause[token]; + _requireReady(pending.effectiveTime); + _assetSuspended[token] = false; + delete _pendingAssetUnpause[token]; + emit Events.AssetUnpaused(token, msg.sender); + _recordPause(ASSET_SCOPE, token, true, false, pending.reasonCode, bytes32(0), pending.effectiveTime); + } + + function _executeVenueUnpause(address venue) internal { + PendingUnpause memory pending = _pendingVenueUnpause[venue]; + _requireReady(pending.effectiveTime); + _venueSuspended[venue] = false; + delete _pendingVenueUnpause[venue]; + emit Events.VenueUnpaused(venue, msg.sender); + _recordPause(VENUE_SCOPE, venue, true, false, pending.reasonCode, bytes32(0), pending.effectiveTime); + } + + function _readyTime() internal view returns (uint64) { + return uint64(block.timestamp + MIN_UNPAUSE_DELAY); + } + + function _requireReady(uint64 readyAt) internal view { + if (readyAt == 0) revert Errors.PendingActionNotFound(); + if (block.timestamp < readyAt) revert Errors.TimelockNotReady(readyAt); + } + + function _recordPause( + bytes32 scope, + address target, + bool oldValue, + bool newValue, + bytes32 reasonCode, + bytes32 reasonHash, + uint64 effectiveTime + ) internal { + bytes32 nextHistoryHash = keccak256( + abi.encode( + address(this), + pauseHistoryHash, + scope, + target, + oldValue, + newValue, + msg.sender, + reasonCode, + reasonHash, + effectiveTime + ) + ); + pauseHistoryHash = nextHistoryHash; + emit Events.PauseActionRecorded( + scope, target, oldValue, newValue, msg.sender, reasonCode, reasonHash, effectiveTime, nextHistoryHash + ); } } diff --git a/src/registry/TokenPolicyRegistry.sol b/src/registry/TokenPolicyRegistry.sol index a0ac419..b74c4e6 100644 --- a/src/registry/TokenPolicyRegistry.sol +++ b/src/registry/TokenPolicyRegistry.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.17; import {Governed} from "../auth/Governed.sol"; import {ITokenPolicyRegistry} from "../interfaces/compliance/ITokenPolicyRegistry.sol"; -import {ManifestCore, PolicyStatus} from "../types/ComplianceTypes.sol"; +import {ManifestCore, PolicyStatus, RecipeBinding, RecipeBindingMode} from "../types/ComplianceTypes.sol"; import {Errors} from "../libraries/Errors.sol"; import {Events} from "../libraries/Events.sol"; @@ -13,7 +13,7 @@ import {Events} from "../libraries/Events.sol"; /// raw status setter: /// /// UNKNOWN --registerManifest--> PROPOSED --approveManifest--> ACTIVE -/// ACTIVE --suspendManifest--> SUSPENDED --resumeManifest--> ACTIVE +/// ACTIVE --suspendManifest--> SUSPENDED --schedule/resume--> ACTIVE /// {ACTIVE, SUSPENDED} --retireManifest--> RETIRED (terminal) /// UNKNOWN --setUnregulated--> UNREGULATED /// @@ -21,16 +21,51 @@ import {Events} from "../libraries/Events.sol"; /// terminal RETIRED manifest; every other transition that is not drawn /// above reverts {Errors.InvalidManifestTransition}. Governance (owner) /// declares/classifies a token (register, setUnregulated); an operator -/// drives the lifecycle of an existing manifest (approve/suspend/ -/// resume/retire). +/// drives immediate tightening and delayed action execution. Governance +/// schedules reopening and semantic updates through the registry owner. contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { + uint64 public constant MIN_MANIFEST_DELAY = 1 days; + uint256 public constant MAX_RECIPE_BINDINGS = 8; + + struct PendingManifestUpdate { + ManifestCore manifest; + uint64 effectiveTime; + bytes32 reasonCode; + } + + struct PendingResume { + uint64 effectiveTime; + bytes32 reasonCode; + } + mapping(address => ManifestCore) internal _manifests; + mapping(address => RecipeBinding[]) internal _recipeBindings; + mapping(address => uint64) internal _manifestVersions; + mapping(address => bytes32) internal _manifestHistoryHashes; + mapping(address => PendingManifestUpdate) internal _pendingManifestUpdates; + mapping(address => RecipeBinding[]) internal _pendingManifestBindings; + mapping(address => PendingResume) internal _pendingManifestResumes; /// @notice Declare a token's manifest. Always lands in PROPOSED regardless of /// the caller-supplied `m.status`; records the declarer. Allowed only /// from UNKNOWN (never declared) or RETIRED (terminal, being re-issued). + function registerManifest(address token, ManifestCore calldata m, RecipeBinding[] calldata bindings) + external + onlyOwner + { + _registerManifest(token, m, bindings); + } + + /// @dev Deprecated compatibility entrypoint. Runtime evaluation never reads + /// the legacy fields after they are compiled into RecipeBinding[]. function registerManifest(address token, ManifestCore calldata m) external onlyOwner { + _registerManifest(token, m, _legacyBindings(m)); + } + + function _registerManifest(address token, ManifestCore memory m, RecipeBinding[] memory bindings) internal { + _validateBindings(bindings); PolicyStatus current = _manifests[token].status; + bytes32 oldHash = _manifests[token].fullManifestHash; // Re-registration only from a clean slate (never declared) or a terminal // RETIRED manifest; an in-flight PROPOSED/ACTIVE/SUSPENDED manifest or an // UNREGULATED tag must not be silently overwritten. @@ -41,7 +76,19 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { _manifests[token].status = PolicyStatus.PROPOSED; _manifests[token].declaredBy = msg.sender; _manifests[token].approvedBy = address(0); - emit Events.ManifestRegistered(token, m.issuanceRecipeId, msg.sender); + _replaceBindings(_recipeBindings[token], bindings); + _recordHistory( + token, + current, + PolicyStatus.PROPOSED, + oldHash, + _manifests[token].fullManifestHash, + bytes32(0), + bytes32(0), + uint64(block.timestamp), + true + ); + emit Events.ManifestRegistered(token, keccak256(abi.encode(bindings)), msg.sender); emit Events.ManifestStatusChanged(token, PolicyStatus.PROPOSED, bytes32(0)); } @@ -49,12 +96,20 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { function approveManifest(address token) external onlyOperator { ManifestCore storage mm = _manifests[token]; if (mm.status != PolicyStatus.PROPOSED) revert Errors.InvalidManifestTransition(); - // Registry-level completeness floor: an approvable manifest must declare - // at least an issuance recipe (id 0 = none). Deep validation stays the - // engine's fail-closed job. Recipe id 0 is never a registered recipe. - if (mm.issuanceRecipeId == 0) revert Errors.RecipeNotRegistered(0); + if (_recipeBindings[token].length == 0) revert Errors.InvalidRecipeBinding(); mm.status = PolicyStatus.ACTIVE; mm.approvedBy = msg.sender; + _recordHistory( + token, + PolicyStatus.PROPOSED, + PolicyStatus.ACTIVE, + mm.fullManifestHash, + mm.fullManifestHash, + bytes32(0), + bytes32(0), + uint64(block.timestamp), + false + ); emit Events.ManifestStatusChanged(token, PolicyStatus.ACTIVE, bytes32(0)); } @@ -62,14 +117,140 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { function suspendManifest(address token, bytes32 reasonCode) external onlyOperator { if (_manifests[token].status != PolicyStatus.ACTIVE) revert Errors.InvalidManifestTransition(); _manifests[token].status = PolicyStatus.SUSPENDED; + delete _pendingManifestResumes[token]; + _recordHistory( + token, + PolicyStatus.ACTIVE, + PolicyStatus.SUSPENDED, + _manifests[token].fullManifestHash, + _manifests[token].fullManifestHash, + reasonCode, + bytes32(0), + uint64(block.timestamp), + false + ); emit Events.ManifestStatusChanged(token, PolicyStatus.SUSPENDED, reasonCode); } - /// @notice SUSPENDED -> ACTIVE (undo a suspension). + /// @notice Owner schedules a compliance reopening; execution is delayed. + function scheduleManifestResume(address token, bytes32 reasonCode) external onlyOwner { + if (_manifests[token].status != PolicyStatus.SUSPENDED) revert Errors.InvalidManifestTransition(); + if (_pendingManifestResumes[token].effectiveTime != 0) revert Errors.PendingActionExists(); + _pendingManifestResumes[token] = PendingResume(_readyTime(), reasonCode); + emit Events.ManifestResumeScheduled(token, reasonCode, _pendingManifestResumes[token].effectiveTime); + } + + function cancelManifestResume(address token) external onlyOwner { + if (_pendingManifestResumes[token].effectiveTime == 0) revert Errors.PendingActionNotFound(); + delete _pendingManifestResumes[token]; + emit Events.ManifestResumeCancelled(token); + } + + /// @notice Execute a scheduled SUSPENDED -> ACTIVE reopening after delay. function resumeManifest(address token) external onlyOperator { if (_manifests[token].status != PolicyStatus.SUSPENDED) revert Errors.InvalidManifestTransition(); + PendingResume memory pending = _pendingManifestResumes[token]; + _requireReady(pending.effectiveTime); _manifests[token].status = PolicyStatus.ACTIVE; - emit Events.ManifestStatusChanged(token, PolicyStatus.ACTIVE, bytes32(0)); + delete _pendingManifestResumes[token]; + _recordHistory( + token, + PolicyStatus.SUSPENDED, + PolicyStatus.ACTIVE, + _manifests[token].fullManifestHash, + _manifests[token].fullManifestHash, + pending.reasonCode, + bytes32(0), + pending.effectiveTime, + false + ); + emit Events.ManifestStatusChanged(token, PolicyStatus.ACTIVE, pending.reasonCode); + } + + /// @notice Owner proposes a hash-bearing manifest update for ACTIVE/SUSPENDED + /// manifests. Full legal/compliance docs remain offchain. + function scheduleManifestUpdate( + address token, + ManifestCore calldata m, + RecipeBinding[] calldata bindings, + bytes32 reasonCode + ) external onlyOwner { + _scheduleManifestUpdate(token, m, bindings, reasonCode); + } + + /// @dev Deprecated compatibility entrypoint; see {registerManifest}. + function scheduleManifestUpdate(address token, ManifestCore calldata m, bytes32 reasonCode) external onlyOwner { + _scheduleManifestUpdate(token, m, _legacyBindings(m), reasonCode); + } + + function _scheduleManifestUpdate( + address token, + ManifestCore memory m, + RecipeBinding[] memory bindings, + bytes32 reasonCode + ) internal { + _validateBindings(bindings); + PolicyStatus current = _manifests[token].status; + if (current != PolicyStatus.ACTIVE && current != PolicyStatus.SUSPENDED) { + revert Errors.InvalidManifestTransition(); + } + if (_pendingManifestUpdates[token].effectiveTime != 0) revert Errors.PendingActionExists(); + if (m.fullManifestHash == bytes32(0) || m.fullManifestHash == _manifests[token].fullManifestHash) { + revert Errors.InvalidManifestHash(); + } + + uint64 effectiveTime = _readyTime(); + _pendingManifestUpdates[token] = PendingManifestUpdate(m, effectiveTime, reasonCode); + _replaceBindings(_pendingManifestBindings[token], bindings); + emit Events.ManifestSemanticUpdateScheduled( + token, + _manifestVersions[token], + _manifestVersions[token] + 1, + _manifests[token].fullManifestHash, + m.fullManifestHash, + reasonCode, + effectiveTime + ); + } + + function cancelManifestUpdate(address token) external onlyOwner { + if (_pendingManifestUpdates[token].effectiveTime == 0) revert Errors.PendingActionNotFound(); + delete _pendingManifestUpdates[token]; + delete _pendingManifestBindings[token]; + emit Events.ManifestSemanticUpdateCancelled(token); + } + + /// @notice Activate a scheduled semantic update after delay. If the manifest + /// was SUSPENDED before activation, it remains SUSPENDED. + function activateManifestUpdate(address token) external onlyOperator { + PolicyStatus current = _manifests[token].status; + if (current != PolicyStatus.ACTIVE && current != PolicyStatus.SUSPENDED) { + revert Errors.InvalidManifestTransition(); + } + + PendingManifestUpdate storage pending = _pendingManifestUpdates[token]; + _requireReady(pending.effectiveTime); + + uint64 oldVersion = _manifestVersions[token]; + bytes32 oldHash = _manifests[token].fullManifestHash; + bytes32 newHash = pending.manifest.fullManifestHash; + bytes32 reasonCode = pending.reasonCode; + uint64 effectiveTime = pending.effectiveTime; + + ManifestCore memory next = pending.manifest; + next.status = current; + next.declaredBy = owner(); + next.approvedBy = msg.sender; + _manifests[token] = next; + _copyBindings(_recipeBindings[token], _pendingManifestBindings[token]); + uint64 newVersion = + _recordHistory(token, current, current, oldHash, newHash, reasonCode, bytes32(0), effectiveTime, true); + delete _pendingManifestUpdates[token]; + delete _pendingManifestBindings[token]; + + emit Events.ManifestSemanticUpdateActivated( + token, oldVersion, newVersion, oldHash, newHash, reasonCode, effectiveTime + ); } /// @notice {ACTIVE, SUSPENDED} -> RETIRED (terminal; re-issue via re-register). @@ -79,6 +260,20 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { revert Errors.InvalidManifestTransition(); } _manifests[token].status = PolicyStatus.RETIRED; + delete _pendingManifestUpdates[token]; + delete _pendingManifestBindings[token]; + delete _pendingManifestResumes[token]; + _recordHistory( + token, + current, + PolicyStatus.RETIRED, + _manifests[token].fullManifestHash, + _manifests[token].fullManifestHash, + reasonCode, + bytes32(0), + uint64(block.timestamp), + false + ); emit Events.ManifestStatusChanged(token, PolicyStatus.RETIRED, reasonCode); } @@ -87,6 +282,17 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { function setUnregulated(address token) external onlyOwner { if (_manifests[token].status != PolicyStatus.UNKNOWN) revert Errors.InvalidManifestTransition(); _manifests[token].status = PolicyStatus.UNREGULATED; + _recordHistory( + token, + PolicyStatus.UNKNOWN, + PolicyStatus.UNREGULATED, + bytes32(0), + bytes32(0), + bytes32(0), + bytes32(0), + uint64(block.timestamp), + false + ); emit Events.ManifestStatusChanged(token, PolicyStatus.UNREGULATED, bytes32(0)); } @@ -98,6 +304,17 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { function clearUnregulated(address token) external onlyOwner { if (_manifests[token].status != PolicyStatus.UNREGULATED) revert Errors.InvalidManifestTransition(); _manifests[token].status = PolicyStatus.UNKNOWN; + _recordHistory( + token, + PolicyStatus.UNREGULATED, + PolicyStatus.UNKNOWN, + bytes32(0), + bytes32(0), + bytes32(0), + bytes32(0), + uint64(block.timestamp), + false + ); emit Events.ManifestStatusChanged(token, PolicyStatus.UNKNOWN, bytes32(0)); } @@ -105,13 +322,153 @@ contract TokenPolicyRegistry is ITokenPolicyRegistry, Governed { return _manifests[token]; } + function recipeBindingsOf(address token) external view returns (RecipeBinding[] memory) { + return _recipeBindings[token]; + } + function statusOf(address token) external view returns (PolicyStatus) { return _manifests[token].status; } + function manifestVersionOf(address token) external view returns (uint64) { + return _manifestVersions[token]; + } + + function manifestHistoryHashOf(address token) external view returns (bytes32) { + return _manifestHistoryHashes[token]; + } + + function pendingManifestUpdateOf(address token) + external + view + returns ( + ManifestCore memory manifest, + RecipeBinding[] memory bindings, + uint64 effectiveTime, + bytes32 reasonCode + ) + { + PendingManifestUpdate storage pending = _pendingManifestUpdates[token]; + return (pending.manifest, _pendingManifestBindings[token], pending.effectiveTime, pending.reasonCode); + } + + function pendingManifestResumeOf(address token) external view returns (uint64 effectiveTime, bytes32 reasonCode) { + PendingResume storage pending = _pendingManifestResumes[token]; + return (pending.effectiveTime, pending.reasonCode); + } + function setFact(address token, uint256 factsPacked) external onlyOperator { + PolicyStatus current = _manifests[token].status; + if (current != PolicyStatus.PROPOSED) revert Errors.InvalidManifestTransition(); uint256 old = _manifests[token].factsPacked; if (factsPacked & old != old) revert Errors.LooseningForbidden(); _manifests[token].factsPacked = factsPacked; } + + function _recordHistory( + address token, + PolicyStatus oldStatus, + PolicyStatus newStatus, + bytes32 oldManifestHash, + bytes32 newManifestHash, + bytes32 reasonCode, + bytes32 reasonHash, + uint64 effectiveTime, + bool incrementVersion + ) internal returns (uint64 newVersion) { + newVersion = _manifestVersions[token]; + if (incrementVersion) newVersion += 1; + bytes32 historyHash = keccak256( + abi.encode( + address(this), + token, + _manifestHistoryHashes[token], + oldStatus, + newStatus, + oldManifestHash, + newManifestHash, + newVersion, + msg.sender, + reasonCode, + reasonHash, + effectiveTime + ) + ); + _manifestVersions[token] = newVersion; + _manifestHistoryHashes[token] = historyHash; + emit Events.ManifestHistoryAppended( + token, + newVersion, + oldStatus, + newStatus, + oldManifestHash, + newManifestHash, + historyHash, + msg.sender, + reasonCode, + reasonHash, + effectiveTime + ); + } + + function _validateBindings(RecipeBinding[] memory bindings) internal pure { + if (bindings.length == 0) revert Errors.InvalidRecipeBinding(); + if (bindings.length > MAX_RECIPE_BINDINGS) { + revert Errors.TooManyRecipeBindings(bindings.length, MAX_RECIPE_BINDINGS); + } + + bool hasBlockingBinding; + for (uint256 i = 0; i < bindings.length; i++) { + RecipeBinding memory binding = bindings[i]; + if (binding.recipeId == 0 || binding.recipeVersion == 0) revert Errors.InvalidRecipeBinding(); + if (binding.mode == RecipeBindingMode.PATH_OPTION) { + if (binding.pathGroupId == 0) revert Errors.InvalidRecipeBinding(); + hasBlockingBinding = true; + } else { + if (binding.pathGroupId != 0) revert Errors.InvalidRecipeBinding(); + if (binding.mode == RecipeBindingMode.REQUIRED_BLOCKING) hasBlockingBinding = true; + } + for (uint256 j = 0; j < i; j++) { + if (bindings[j].recipeId == binding.recipeId) { + revert Errors.DuplicateRecipeBinding(binding.recipeId); + } + } + } + if (!hasBlockingBinding) revert Errors.InvalidRecipeBinding(); + } + + function _replaceBindings(RecipeBinding[] storage target, RecipeBinding[] memory source) internal { + while (target.length != 0) target.pop(); + for (uint256 i = 0; i < source.length; i++) { + target.push(source[i]); + } + } + + function _copyBindings(RecipeBinding[] storage target, RecipeBinding[] storage source) internal { + while (target.length != 0) target.pop(); + for (uint256 i = 0; i < source.length; i++) { + target.push(source[i]); + } + } + + function _legacyBindings(ManifestCore calldata manifest) internal pure returns (RecipeBinding[] memory bindings) { + uint256 count = manifest.issuanceRecipeId == 0 ? 0 : (manifest.fundRecipeId == 0 ? 1 : 2); + bindings = new RecipeBinding[](count); + if (count == 0) return bindings; + uint16 issuanceVersion = manifest.issuanceRecipeVersion == 0 ? 1 : manifest.issuanceRecipeVersion; + bindings[0] = + RecipeBinding(manifest.issuanceRecipeId, issuanceVersion, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + if (count == 2) { + bindings[1] = RecipeBinding(manifest.fundRecipeId, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 90); + } + } + + function _readyTime() internal view returns (uint64) { + return uint64(block.timestamp + MIN_MANIFEST_DELAY); + } + + function _requireReady(uint64 readyAt) internal view { + if (readyAt == 0) revert Errors.PendingActionNotFound(); + if (block.timestamp < readyAt) revert Errors.TimelockNotReady(readyAt); + } } diff --git a/src/types/ComplianceTypes.sol b/src/types/ComplianceTypes.sol index 945ccf3..0435fad 100644 --- a/src/types/ComplianceTypes.sol +++ b/src/types/ComplianceTypes.sol @@ -38,6 +38,20 @@ enum FlowType { REDEMPTION } +enum RecipeBindingMode { + REQUIRED_BLOCKING, + PATH_OPTION, + FLAG_ONLY +} + +struct RecipeBinding { + uint16 recipeId; + uint16 recipeVersion; + RecipeBindingMode mode; + uint16 pathGroupId; + uint8 priority; +} + // 04-element-interface.md §2-3 (stable, verbatim) enum ElementCategory { INVESTOR_ATTRIBUTE, @@ -85,6 +99,8 @@ struct ElementMetadata { struct ManifestCore { PolicyStatus status; + // Deprecated compatibility mirrors. Runtime evaluation uses the registry's + // RecipeBinding[] exclusively; remove these fields at the next major ABI. uint16 issuanceRecipeId; uint16 issuanceRecipeVersion; uint16 fundRecipeId; @@ -122,5 +138,6 @@ struct ComplianceDecision { bytes32 allowedVenuesHash; bytes32 reasonCode; bytes32 reliedClaims; + uint256 flagsBitmap; bytes32 decisionHash; } diff --git a/test/integration/BUIDLLikeFlow.t.sol b/test/integration/BUIDLLikeFlow.t.sol index 8a5148a..e6fff24 100644 --- a/test/integration/BUIDLLikeFlow.t.sol +++ b/test/integration/BUIDLLikeFlow.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.17; import {IntegrationBase} from "./IntegrationBase.sol"; import {BuidlLikeDemoAsset} from "../../src/demo/BuidlLikeDemoAsset.sol"; import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; +import {RecipeBinding} from "../../src/types/ComplianceTypes.sol"; /// @notice Local BUIDL-like ERC-3643 demo asset flow. /// @@ -23,16 +24,10 @@ contract BUIDLLikeFlowTest is IntegrationBase { assertEq(rwaToken.name(), BuidlLikeDemoAsset.TOKEN_NAME, "demo asset name"); assertEq(rwaToken.symbol(), BuidlLikeDemoAsset.TOKEN_SYMBOL, "demo asset symbol"); assertEq(uint8(policyReg.statusOf(address(rwaToken))), 2, "manifest active"); - assertEq( - policyReg.manifestOf(address(rwaToken)).issuanceRecipeId, - BuidlLikeDemoAsset.ISSUANCE_RECIPE_ID, - "RegD 506c recipe" - ); - assertEq( - policyReg.manifestOf(address(rwaToken)).fundRecipeId, - BuidlLikeDemoAsset.FUND_RECIPE_ID, - "BUIDL-like fund recipe" - ); + RecipeBinding[] memory bindings = policyReg.recipeBindingsOf(address(rwaToken)); + assertEq(bindings.length, 2, "two recipe bindings"); + assertEq(bindings[0].recipeId, BuidlLikeDemoAsset.ISSUANCE_RECIPE_ID, "RegD 506c recipe"); + assertEq(bindings[1].recipeId, BuidlLikeDemoAsset.FUND_RECIPE_ID, "BUIDL-like fund recipe"); assertEq( policyReg.manifestOf(address(rwaToken)).factsPacked & BuidlLikeDemoAsset.FACT_FUND_APPLICABLE, BuidlLikeDemoAsset.FACT_FUND_APPLICABLE, diff --git a/test/integration/EmergencyPause.t.sol b/test/integration/EmergencyPause.t.sol index 1b37baf..c60752d 100644 --- a/test/integration/EmergencyPause.t.sol +++ b/test/integration/EmergencyPause.t.sol @@ -49,13 +49,32 @@ contract EmergencyPauseTest is IntegrationBase { assertEq(quote.balanceOf(alice), 1_000 ether, "no quote spent"); } + function test_globalPause_blocksSwapBeforeSettlement() public { + operatorReg.setGlobalPaused(true, bytes32("SECURITY_INCIDENT")); + ExecutionRequest memory req = buildBuyRequest(alice, 50 ether, 50 ether); + vm.prank(alice); + vm.expectRevert(Errors.GlobalPaused.selector); + router.execute(req); + assertEq(rwaToken.balanceOf(alice), 0); + assertEq(quote.balanceOf(alice), 1_000 ether); + } + + function test_assetPause_blocksEitherPairSide() public { + operatorReg.setAssetSuspended(address(rwaToken), true, bytes32("LEGAL_REQUEST")); + ExecutionRequest memory req = buildBuyRequest(alice, 50 ether, 50 ether); + vm.prank(alice); + vm.expectRevert(Errors.TokenOutPaused.selector); + router.execute(req); + assertEq(rwaToken.balanceOf(alice), 0); + } + // A PROPOSED (registered but not yet operator-approved) manifest must be // rejected end-to-end: the engine's default-deny gate fails closed before any // recipe runs. Drive rwaToken back to PROPOSED via retire -> register // (deployStack left it ACTIVE and re-register over ACTIVE is illegal). function test_proposedPolicy_failsClosed() public { policyReg.retireManifest(address(rwaToken), bytes32("REISSUE")); - policyReg.registerManifest(address(rwaToken), _activeManifest(0, 0)); // PROPOSED, not approved + policyReg.registerManifest(address(rwaToken), _activeManifest(0, 0), _bindings(0)); // PROPOSED, not approved ExecutionRequest memory req = buildBuyRequest(alice, 50 ether, 50 ether); vm.prank(alice); @@ -91,7 +110,9 @@ contract EmergencyPauseTest is IntegrationBase { router.execute(blocked); assertEq(rwaToken.balanceOf(alice), 0, "blocked while suspended"); - // resume → ACTIVE again → trade settles. + // delayed resume → ACTIVE again → trade settles. + policyReg.scheduleManifestResume(address(rwaToken), bytes32("RECOVERED")); + vm.warp(block.timestamp + policyReg.MIN_MANIFEST_DELAY()); policyReg.resumeManifest(address(rwaToken)); ExecutionRequest memory ok = buildBuyRequest(alice, 50 ether, 50 ether); vm.prank(alice); diff --git a/test/integration/IntegrationBase.sol b/test/integration/IntegrationBase.sol index 9e33115..636dfe2 100644 --- a/test/integration/IntegrationBase.sol +++ b/test/integration/IntegrationBase.sol @@ -38,7 +38,14 @@ import {MockERC20} from "../mocks/MockERC20.sol"; import {MockPool} from "../mocks/MockPool.sol"; import {BuidlLikeDemoAsset} from "../../src/demo/BuidlLikeDemoAsset.sol"; -import {ManifestCore, PolicyStatus, VenueType, FlowType} from "../../src/types/ComplianceTypes.sol"; +import { + ManifestCore, + PolicyStatus, + RecipeBinding, + RecipeBindingMode, + VenueType, + FlowType +} from "../../src/types/ComplianceTypes.sol"; import {ComplianceContext} from "../../src/types/ComplianceTypes.sol"; import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; import {VenueConfig, CustodyModel} from "../../src/types/VenueTypes.sol"; @@ -126,13 +133,18 @@ abstract contract IntegrationBase is TREXSuite { uint16 fundRecipeId, uint256 factsPacked ) internal { - deployStackWithManifest(tokenName, tokenSymbol, _activeManifest(fundRecipeId, factsPacked)); + deployStackWithManifest( + tokenName, tokenSymbol, _activeManifest(fundRecipeId, factsPacked), _bindings(fundRecipeId) + ); } /// @notice Stand up the same stack with an explicit asset Manifest/profile. - function deployStackWithManifest(string memory tokenName, string memory tokenSymbol, ManifestCore memory manifest) - internal - { + function deployStackWithManifest( + string memory tokenName, + string memory tokenSymbol, + ManifestCore memory manifest, + RecipeBinding[] memory bindings + ) internal { deployTREX(tokenName, tokenSymbol); // real ERC-3643 token() + identity registry // 1. compliance registries @@ -200,7 +212,7 @@ abstract contract IntegrationBase is TREXSuite { // 7. manifests: onboarding goes through the lifecycle (propose -> approve). // Keep the caller-provided manifest so BUIDL-like profiles can bind // their own fund recipe/facts while still using the current lifecycle. - policyReg.registerManifest(address(rwaToken), manifest); + policyReg.registerManifest(address(rwaToken), manifest, bindings); policyReg.approveManifest(address(rwaToken)); // Quote/cash is out-of-scope: tag UNREGULATED directly from UNKNOWN. policyReg.setUnregulated(address(quote)); @@ -246,7 +258,10 @@ abstract contract IntegrationBase is TREXSuite { /// This is a local demo asset, not integration with real BlackRock BUIDL. function deployBuidlLikeStack() internal { deployStackWithManifest( - BuidlLikeDemoAsset.TOKEN_NAME, BuidlLikeDemoAsset.TOKEN_SYMBOL, BuidlLikeDemoAsset.manifest(ENGINES_AMM) + BuidlLikeDemoAsset.TOKEN_NAME, + BuidlLikeDemoAsset.TOKEN_SYMBOL, + BuidlLikeDemoAsset.manifest(ENGINES_AMM), + BuidlLikeDemoAsset.recipeBindings() ); } @@ -254,13 +269,18 @@ abstract contract IntegrationBase is TREXSuite { function _activeManifest(uint16 fundRecipeId, uint256 factsPacked) internal pure returns (ManifestCore memory m) { m.status = PolicyStatus.ACTIVE; - m.issuanceRecipeId = 1; - m.issuanceRecipeVersion = 1; - m.fundRecipeId = fundRecipeId; m.supportedEngines = ENGINES_AMM; // AMM bit → selector.validate passes for AMM m.factsPacked = factsPacked; } + function _bindings(uint16 fundRecipeId) internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](fundRecipeId == 0 ? 1 : 2); + bindings[0] = RecipeBinding(1, 2, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + if (fundRecipeId != 0) { + bindings[1] = RecipeBinding(fundRecipeId, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 90); + } + } + // --- actor setup ------------------------------------------------------ /// @notice Make `who` a fully-compliant investor for the 9-element Reg D @@ -428,13 +448,19 @@ abstract contract IntegrationBase is TREXSuite { /// @dev Test-only settable acquisition-time source for the Lockup (C-01-v1) /// element's injected CR-3 seam. Mirrors the unit-test helper. contract MockAcquisitionSource is IAcquisitionSource { - mapping(bytes32 => uint64) internal _acquiredAt; + mapping(bytes32 => AcquisitionSnapshot) internal _snapshots; function setAcquiredAt(address holder, address asset, uint64 ts) external { - _acquiredAt[keccak256(abi.encode(holder, asset))] = ts; + _snapshots[keccak256(abi.encode(holder, asset))] = AcquisitionSnapshot({ + clockStart: ts, + observedAt: uint64(block.timestamp), + expiresAt: type(uint64).max, + sourceRef: keccak256("integration-fixture"), + status: AcquisitionStatus.VALID + }); } - function acquiredAt(address holder, address asset) external view override returns (uint64) { - return _acquiredAt[keccak256(abi.encode(holder, asset))]; + function acquisitionOf(address holder, address asset) external view override returns (AcquisitionSnapshot memory) { + return _snapshots[keccak256(abi.encode(holder, asset))]; } } diff --git a/test/integration/RFQFlow.t.sol b/test/integration/RFQFlow.t.sol index 4c93e46..e1767b8 100644 --- a/test/integration/RFQFlow.t.sol +++ b/test/integration/RFQFlow.t.sol @@ -104,7 +104,7 @@ contract RFQFlowTest is IntegrationBase { ManifestCore memory m = _activeManifest(0, 0); m.supportedEngines = ENGINES_AMM | ENGINES_RFQ; policyReg.retireManifest(address(rwaToken), bytes32("re-engine-rfq")); - policyReg.registerManifest(address(rwaToken), m); + policyReg.registerManifest(address(rwaToken), m, _bindings(0)); policyReg.approveManifest(address(rwaToken)); } diff --git a/test/integration/RealUniswapV3.t.sol b/test/integration/RealUniswapV3.t.sol new file mode 100644 index 0000000..b367027 --- /dev/null +++ b/test/integration/RealUniswapV3.t.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IntegrationBase} from "./IntegrationBase.sol"; +import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; +import {VenueType} 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"; + +interface ICanonicalUniswapV3Factory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); +} + +interface ICanonicalUniswapV3Pool { + function initialize(uint160 sqrtPriceX96) external; + + function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) + external + returns (uint256 amount0, uint256 amount1); + + function token0() external view returns (address); + + function token1() external view returns (address); +} + +/// @notice Canonical Uniswap v3 core integration without copying vendored source +/// into the product tree. The factory/pool creation code is loaded from the +/// pinned Uniswap v3 core package artifact under tools/deploy-v3. +contract RealUniswapV3Test is IntegrationBase { + string internal constant FACTORY_ARTIFACT = + "tools/deploy-v3/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json"; + string internal constant POOL_ARTIFACT = + "tools/deploy-v3/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Pool.sol/UniswapV3Pool.json"; + + uint24 internal constant FEE = 3000; + uint160 internal constant SQRT_PRICE_1_TO_1 = 1 << 96; + uint160 internal constant MIN_SQRT_RATIO_PLUS_ONE = 4_295_128_740; + uint160 internal constant MAX_SQRT_RATIO_MINUS_ONE = + 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_341; + + address internal realFactory; + address internal realPool; + bytes32 internal poolInitCodeHash; + bool internal quoteIsToken0; + address internal mintCallbackPool; + + function setUp() public { + deployStack(); + _deployAndSeedCanonicalPool(); + } + + function test_factoryCreate2AddressMatchesCanonicalPreflight() public view { + assertEq(realPool, _computePoolAddress(realFactory, address(quote), address(rwaToken), FEE)); + assertEq(ICanonicalUniswapV3Pool(realPool).token0(), quoteIsToken0 ? address(quote) : address(rwaToken)); + assertEq(ICanonicalUniswapV3Pool(realPool).token1(), quoteIsToken0 ? address(rwaToken) : address(quote)); + } + + function test_protectedBuy_usesCanonicalPoolAndLeavesAdapterNonCustodial() public { + setupBuyer(alice); + fundBuyerQuote(alice, 1_000 ether); + + uint256 amountIn = 100 ether; + uint256 quoteBefore = quote.balanceOf(alice); + uint256 rwaBefore = rwaToken.balanceOf(alice); + ExecutionRequest memory req = _realPoolBuyRequest(alice, amountIn); + + doBuy(req); + + assertEq(quote.balanceOf(alice), quoteBefore - amountIn, "buyer paid exact quote input"); + assertGt(rwaToken.balanceOf(alice), rwaBefore, "canonical pool delivered ERC-3643 RWA"); + assertEq(quote.balanceOf(address(router)), 0, "router keeps no quote custody"); + assertEq(rwaToken.balanceOf(address(router)), 0, "router keeps no RWA custody"); + assertEq(quote.balanceOf(address(adapter)), 0, "adapter keeps no quote custody"); + assertEq(rwaToken.balanceOf(address(adapter)), 0, "adapter keeps no RWA custody"); + } + + function test_protectedSell_usesCanonicalPoolInReverseDirection() public { + setupBuyer(alice); + mint(alice, 500 ether); + vm.prank(alice); + rwaToken.approve(address(adapter), type(uint256).max); + + uint256 amountIn = 100 ether; + uint256 rwaBefore = rwaToken.balanceOf(alice); + uint256 quoteBefore = quote.balanceOf(alice); + ExecutionRequest memory req = buildBuyRequest(alice, amountIn, 0); + req.context.seller = realPool; + req.context.tokenIn = address(rwaToken); + req.context.tokenOut = address(quote); + req.context.venue = realPool; + bool rwaIsToken0 = !quoteIsToken0; + req.venueData = abi.encode(rwaIsToken0, _priceLimit(rwaIsToken0)); + + doBuy(req); + + assertEq(rwaToken.balanceOf(alice), rwaBefore - amountIn, "seller paid exact RWA input"); + assertGt(quote.balanceOf(alice), quoteBefore, "canonical pool delivered quote"); + } + + function test_complianceRejectionOccursBeforeCanonicalPoolBalancesMove() public { + verifyInvestor(alice); + attestInvestorExceptAccredited(alice); + fundBuyerQuote(alice, 1_000 ether); + + uint256 poolQuoteBefore = quote.balanceOf(realPool); + uint256 poolRwaBefore = rwaToken.balanceOf(realPool); + ExecutionRequest memory req = _realPoolBuyRequest(alice, 100 ether); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + Errors.ComplianceRejected.selector, ReasonCodes.encode(1, bytes32("A-03-v1"), uint32(1)) + ) + ); + router.execute(req); + + assertEq(quote.balanceOf(realPool), poolQuoteBefore, "rejected trade cannot pay pool"); + assertEq(rwaToken.balanceOf(realPool), poolRwaBefore, "rejected trade cannot receive RWA"); + } + + function test_unregisteredCallbackCannotPullBuyerFunds() public { + vm.expectRevert(bytes("callback: unauthorized")); + adapter.uniswapV3SwapCallback(int256(1 ether), 0, abi.encode(alice, address(quote))); + } + + function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { + require(msg.sender == mintCallbackPool, "mint callback: unauthorized"); + ICanonicalUniswapV3Pool pool_ = ICanonicalUniswapV3Pool(msg.sender); + if (amount0Owed != 0) IERC20(pool_.token0()).transfer(msg.sender, amount0Owed); + if (amount1Owed != 0) IERC20(pool_.token1()).transfer(msg.sender, amount1Owed); + } + + function _deployAndSeedCanonicalPool() private { + bytes memory factoryCode = _artifactBytecode(FACTORY_ARTIFACT); + bytes memory poolCode = _artifactBytecode(POOL_ARTIFACT); + poolInitCodeHash = keccak256(poolCode); + realFactory = _deploy(factoryCode); + + address expected = _computePoolAddress(realFactory, address(quote), address(rwaToken), FEE); + realPool = ICanonicalUniswapV3Factory(realFactory).createPool(address(quote), address(rwaToken), FEE); + assertEq(realPool, expected, "factory must use canonical CREATE2 pool address"); + + quoteIsToken0 = address(quote) < address(rwaToken); + registerVenueIdentity(realPool); + venueReg.registerVenue( + realPool, + VenueConfig({ + venueType: VenueType.AMM, + adapter: address(adapter), + target: realPool, + operator: address(0), + custody: CustodyModel.POOL, + active: true + }) + ); + adapter.setPool(realPool, true); + + ICanonicalUniswapV3Pool(realPool).initialize(SQRT_PRICE_1_TO_1); + registerVenueIdentity(address(this)); + quote.mint(address(this), 10_000 ether); + mint(address(this), 10_000 ether); + mintCallbackPool = realPool; + ICanonicalUniswapV3Pool(realPool).mint(address(this), -120, 120, 1_000_000 ether, ""); + mintCallbackPool = address(0); + } + + function _realPoolBuyRequest(address buyer, uint256 amountIn) private returns (ExecutionRequest memory req) { + req = buildBuyRequest(buyer, amountIn, 0); + req.context.seller = realPool; + req.context.venue = realPool; + req.venueData = abi.encode(quoteIsToken0, _priceLimit(quoteIsToken0)); + } + + function _priceLimit(bool zeroForOne) private pure returns (uint160) { + return zeroForOne ? MIN_SQRT_RATIO_PLUS_ONE : MAX_SQRT_RATIO_MINUS_ONE; + } + + function _computePoolAddress(address factory_, address tokenA, address tokenB, uint24 fee) + private + view + returns (address) + { + (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + bytes32 salt = keccak256(abi.encode(token0, token1, fee)); + return address(uint160(uint256(keccak256(abi.encodePacked(hex"ff", factory_, salt, poolInitCodeHash))))); + } + + function _artifactBytecode(string memory path) private view returns (bytes memory) { + return vm.parseJsonBytes(vm.readFile(path), ".bytecode"); + } + + function _deploy(bytes memory creationCode) private returns (address deployed) { + assembly { + deployed := create(0, add(creationCode, 0x20), mload(creationCode)) + } + require(deployed != address(0), "artifact deployment failed"); + } +} diff --git a/test/integration/Surveillance.t.sol b/test/integration/Surveillance.t.sol index ff40069..8a2e05e 100644 --- a/test/integration/Surveillance.t.sol +++ b/test/integration/Surveillance.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.17; import {IntegrationBase} from "./IntegrationBase.sol"; import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; -import {ManifestCore} from "../../src/types/ComplianceTypes.sol"; +import {ManifestCore, RecipeBinding, RecipeBindingMode} from "../../src/types/ComplianceTypes.sol"; import {Events} from "../../src/libraries/Events.sol"; import {ReasonCodes} from "../../src/libraries/ReasonCodes.sol"; @@ -26,12 +26,13 @@ contract SurveillanceTest is IntegrationBase { recipeReg.registerRecipe(7, 1, address(r)); ManifestCore memory m = _activeManifest(0, 0); - m.issuanceRecipeId = 7; + RecipeBinding[] memory bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(7, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); // deployStack already left rwaToken ACTIVE; re-pointing the issuance recipe // goes through the terminal-then-reissue path (retire -> register -> // approve) since re-register over ACTIVE reverts by design. policyReg.retireManifest(address(rwaToken), bytes32("REISSUE")); - policyReg.registerManifest(address(rwaToken), m); + policyReg.registerManifest(address(rwaToken), m, bindings); policyReg.approveManifest(address(rwaToken)); } diff --git a/test/unit/compliance/AcquisitionSource.t.sol b/test/unit/compliance/AcquisitionSource.t.sol new file mode 100644 index 0000000..8faa356 --- /dev/null +++ b/test/unit/compliance/AcquisitionSource.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {AttestedAcquisitionSource} from "../../../src/registry/AttestedAcquisitionSource.sol"; +import {IAcquisitionSource} from "../../../src/interfaces/compliance/IAcquisitionSource.sol"; +import {Lockup} from "../../../src/compliance/elements/Lockup.sol"; +import {Errors} from "../../../src/libraries/Errors.sol"; +import {ReasonCodes} from "../../../src/libraries/ReasonCodes.sol"; + +contract AcquisitionSourceTest is Test { + AttestedAcquisitionSource internal source; + Lockup internal lockup; + address internal holder = address(0xA11CE); + address internal asset = address(0xBEEF); + address internal operator = address(0x0B); + uint64 internal constant LOCKUP = 100; + + function setUp() public { + vm.warp(1_000); + source = new AttestedAcquisitionSource(); + source.setOperator(operator, true); + lockup = new Lockup(address(source), LOCKUP); + } + + function test_validSnapshot_passesAfterLockup() public { + _setValid(800, 2_000); + (bool passed, bytes32 reasonCode) = lockup.check(holder, address(0), asset, 0, ""); + assertTrue(passed); + assertEq(reasonCode, bytes32(0)); + } + + function test_missingSnapshot_failsClosed() public view { + (bool passed, bytes32 reasonCode) = lockup.check(holder, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, ReasonCodes.encode(0, bytes32("C-01-v1"), 1)); + } + + function test_brokenLineage_failsClosed() public { + vm.prank(operator); + source.setSnapshot( + holder, asset, 0, 2_000, keccak256("broken"), IAcquisitionSource.AcquisitionStatus.LINEAGE_BROKEN + ); + (bool passed, bytes32 reasonCode) = lockup.check(holder, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, ReasonCodes.encode(0, bytes32("C-01-v1"), 2)); + } + + function test_expiredSnapshot_failsClosed() public { + _setValid(800, 1_100); + vm.warp(1_101); + (bool passed, bytes32 reasonCode) = lockup.check(holder, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, ReasonCodes.encode(0, bytes32("C-01-v1"), 3)); + } + + function test_immatureSnapshot_failsClosed() public { + _setValid(950, 2_000); + (bool passed, bytes32 reasonCode) = lockup.check(holder, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, ReasonCodes.encode(0, bytes32("C-01-v1"), 4)); + } + + function test_clearSnapshot_restoresMissingState() public { + _setValid(800, 2_000); + vm.prank(operator); + source.clearSnapshot(holder, asset); + IAcquisitionSource.AcquisitionSnapshot memory snapshot = source.acquisitionOf(holder, asset); + assertEq(uint256(snapshot.status), uint256(IAcquisitionSource.AcquisitionStatus.MISSING)); + } + + function test_setSnapshot_revertsForNonOperator() public { + vm.prank(address(0xBAD)); + vm.expectRevert(Errors.NotAuthorized.selector); + source.setSnapshot(holder, asset, 800, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID); + } + + function test_setSnapshot_rejectsInvalidInputs() public { + vm.startPrank(operator); + vm.expectRevert(Errors.ZeroAddress.selector); + source.setSnapshot( + address(0), asset, 800, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID + ); + vm.expectRevert(Errors.ZeroAddress.selector); + source.setSnapshot( + holder, address(0), 800, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID + ); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot(holder, asset, 0, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.MISSING); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot(holder, asset, 0, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot(holder, asset, 1_001, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot( + holder, asset, 800, 2_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.LINEAGE_BROKEN + ); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot(holder, asset, 800, 1_000, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID); + vm.expectRevert(Errors.InvalidAcquisitionSnapshot.selector); + source.setSnapshot(holder, asset, 800, 2_000, bytes32(0), IAcquisitionSource.AcquisitionStatus.VALID); + vm.stopPrank(); + } + + function _setValid(uint64 clockStart, uint64 expiresAt) internal { + vm.prank(operator); + source.setSnapshot( + holder, asset, clockStart, expiresAt, keccak256("source"), IAcquisitionSource.AcquisitionStatus.VALID + ); + } +} diff --git a/test/unit/compliance/Elements.t.sol b/test/unit/compliance/Elements.t.sol index 4ced749..9e8d784 100644 --- a/test/unit/compliance/Elements.t.sol +++ b/test/unit/compliance/Elements.t.sol @@ -20,14 +20,30 @@ import {Events} from "../../../src/libraries/Events.sol"; import {Errors} from "../../../src/libraries/Errors.sol"; contract MockAcquisitionSource is IAcquisitionSource { - mapping(bytes32 => uint64) internal _at; + mapping(bytes32 => AcquisitionSnapshot) internal _snapshots; function set(address holder, address asset, uint64 ts) external { - _at[keccak256(abi.encode(holder, asset))] = ts; + _snapshots[keccak256(abi.encode(holder, asset))] = AcquisitionSnapshot({ + clockStart: ts, + observedAt: uint64(block.timestamp), + expiresAt: type(uint64).max, + sourceRef: keccak256("mock-lot"), + status: AcquisitionStatus.VALID + }); } - function acquiredAt(address holder, address asset) external view returns (uint64) { - return _at[keccak256(abi.encode(holder, asset))]; + function setBroken(address holder, address asset) external { + _snapshots[keccak256(abi.encode(holder, asset))] = AcquisitionSnapshot({ + clockStart: 0, + observedAt: uint64(block.timestamp), + expiresAt: type(uint64).max, + sourceRef: keccak256("broken-lineage"), + status: AcquisitionStatus.LINEAGE_BROKEN + }); + } + + function acquisitionOf(address holder, address asset) external view returns (AcquisitionSnapshot memory) { + return _snapshots[keccak256(abi.encode(holder, asset))]; } } @@ -163,6 +179,16 @@ contract ElementsTest is Test { assertEq(uint256(m.temporal), uint256(TemporalNature.PERIODIC)); } + function test_lockup_failsClosed_onBrokenLineage() public { + MockAcquisitionSource src = new MockAcquisitionSource(); + Lockup l = new Lockup(address(src), 100); + src.setBroken(user, asset); + + (bool passed, bytes32 reasonCode) = l.check(user, address(0), asset, 0, ""); + assertFalse(passed); + assertEq(reasonCode, keccak256(abi.encode(uint16(0), bytes32("C-01-v1"), uint32(2)))); + } + function test_surveillance_never_blocks_and_flags_over_threshold() public { SurveillanceFlag f = new SurveillanceFlag(); f.setEngine(address(this)); // authorize this test as the onTransfer caller diff --git a/test/unit/compliance/Engine.t.sol b/test/unit/compliance/Engine.t.sol index a6692eb..4b4b10d 100644 --- a/test/unit/compliance/Engine.t.sol +++ b/test/unit/compliance/Engine.t.sol @@ -18,6 +18,7 @@ import {AssetClassification} from "../../../src/compliance/elements/AssetClassif import {Erc3643Native} from "../../../src/compliance/elements/Erc3643Native.sol"; import {FormDFiling} from "../../../src/compliance/elements/FormDFiling.sol"; import {IAcquisitionSource} from "../../../src/interfaces/compliance/IAcquisitionSource.sol"; +import {IComplianceElement, IStatefulElement} from "../../../src/interfaces/compliance/IComplianceElement.sol"; import {RegD506cRecipe} from "../../../src/compliance/recipes/RegD506cRecipe.sol"; import {Fund3c7Recipe} from "../../../src/compliance/recipes/Fund3c7Recipe.sol"; import { @@ -25,6 +26,14 @@ import { ComplianceDecision, ManifestCore, PolicyStatus, + RecipeBinding, + RecipeBindingMode, + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness, VenueType, FlowType } from "../../../src/types/ComplianceTypes.sol"; @@ -142,16 +151,38 @@ contract EngineTest is Test { function _activeManifest(uint16 fundRecipeId, uint256 factsPacked) internal pure returns (ManifestCore memory m) { m.status = PolicyStatus.ACTIVE; - m.issuanceRecipeId = 1; - m.issuanceRecipeVersion = 1; - m.fundRecipeId = fundRecipeId; m.supportedEngines = 0x01; // AMM bit m.factsPacked = factsPacked; } + function _bindings(uint16 fundRecipeId) internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](fundRecipeId == 0 ? 1 : 2); + bindings[0] = RecipeBinding(1, 2, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + if (fundRecipeId != 0) { + bindings[1] = RecipeBinding(fundRecipeId, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 90); + } + } + + function _singleBinding(uint16 recipeId, uint16 version) internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(recipeId, version, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + } + + function _registerBindings(RecipeBinding[] memory bindings) internal { + policyReg.registerManifest(RWA, _activeManifest(0, 0), bindings); + policyReg.approveManifest(RWA); + _registerCashUnregulated(); + } + + function _registerSingleElementRecipe(uint16 recipeId, bytes32 elementId) internal { + bytes32[] memory elements = new bytes32[](1); + elements[0] = elementId; + recipeReg.registerRecipe(recipeId, 1, address(new UnregisteredElementRecipe(elements))); + } + function _registerRWA(uint16 fundRecipeId, uint256 factsPacked) internal { // Onboard the legal way: register lands PROPOSED, approve moves it to ACTIVE. - policyReg.registerManifest(RWA, _activeManifest(fundRecipeId, factsPacked)); + policyReg.registerManifest(RWA, _activeManifest(fundRecipeId, factsPacked), _bindings(fundRecipeId)); policyReg.approveManifest(RWA); _registerCashUnregulated(); } @@ -249,6 +280,121 @@ contract EngineTest is Test { assertTrue(d.allowed); } + function test_pathOption_groupPassesWhenAnyPathPasses() public { + _registerSingleElementRecipe(3, bytes32("A-03-v1")); + _registerSingleElementRecipe(4, bytes32("A-13-v1")); + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.PATH_OPTION, 1, 50); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.PATH_OPTION, 1, 40); + _registerBindings(bindings); + + accredited.setAccredited(BUYER, true); + assertTrue(engine.evaluate(_ctxBuy()).allowed, "AI path should satisfy group"); + + accredited.setAccredited(BUYER, false); + qp.setQp(BUYER, true); + assertTrue(engine.evaluate(_ctxBuy()).allowed, "QP path should satisfy group"); + + qp.setQp(BUYER, false); + assertFalse(engine.evaluate(_ctxBuy()).allowed, "all alternatives failing must reject"); + } + + function test_pathOption_groupsAreAndedTogether() public { + _registerSingleElementRecipe(3, bytes32("A-03-v1")); + _registerSingleElementRecipe(4, bytes32("A-13-v1")); + _registerSingleElementRecipe(5, bytes32("A-01-v1")); + _registerSingleElementRecipe(6, bytes32("A-02-v1")); + RecipeBinding[] memory bindings = new RecipeBinding[](4); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.PATH_OPTION, 1, 50); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.PATH_OPTION, 1, 40); + bindings[2] = RecipeBinding(5, 1, RecipeBindingMode.PATH_OPTION, 2, 30); + bindings[3] = RecipeBinding(6, 1, RecipeBindingMode.PATH_OPTION, 2, 20); + _registerBindings(bindings); + + accredited.setAccredited(BUYER, true); + assertTrue(engine.evaluate(_ctxBuy()).allowed, "one pass in every group should allow"); + + sanctions.setBlocked(BUYER, true); + assertFalse(engine.evaluate(_ctxBuy()).allowed, "one failed group must reject"); + } + + function test_requiredBlocking_and_pathGroups_areAnded() public { + _registerSingleElementRecipe(3, bytes32("A-01-v1")); + _registerSingleElementRecipe(4, bytes32("A-03-v1")); + _registerSingleElementRecipe(5, bytes32("A-13-v1")); + RecipeBinding[] memory bindings = new RecipeBinding[](3); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.PATH_OPTION, 1, 50); + bindings[2] = RecipeBinding(5, 1, RecipeBindingMode.PATH_OPTION, 1, 40); + _registerBindings(bindings); + + accredited.setAccredited(BUYER, true); + assertTrue(engine.evaluate(_ctxBuy()).allowed); + sanctions.setBlocked(BUYER, true); + assertFalse(engine.evaluate(_ctxBuy()).allowed, "path success cannot rescue required failure"); + } + + function test_flagOnly_failureDoesNotBlock_butSetsStableBindingBit() public { + _registerSingleElementRecipe(3, bytes32("A-03-v1")); + _registerSingleElementRecipe(4, bytes32("A-13-v1")); + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.FLAG_ONLY, 0, 10); + _registerBindings(bindings); + + accredited.setAccredited(BUYER, true); + ComplianceDecision memory decision = engine.evaluate(_ctxBuy()); + assertTrue(decision.allowed); + assertEq(decision.flagsBitmap, uint256(1) << 1, "flag bit follows binding index"); + + qp.setQp(BUYER, true); + assertEq(engine.evaluate(_ctxBuy()).flagsBitmap, 0, "passing finding clears bit"); + } + + function test_flagOnly_statefulHookCannotRollbackSettlement() public { + bytes32 elementId = bytes32("F-REVERT"); + elementReg.registerElement(elementId, address(new RevertingStatefulElement())); + _registerSingleElementRecipe(3, bytes32("A-03-v1")); + _registerSingleElementRecipe(4, elementId); + + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.FLAG_ONLY, 0, 10); + _registerBindings(bindings); + + accredited.setAccredited(BUYER, true); + ComplianceDecision memory decision = engine.evaluate(_ctxBuy()); + assertTrue(decision.allowed); + assertEq(decision.flagsBitmap, uint256(1) << 1); + + // The element's onTransfer always reverts. FLAG_ONLY must never invoke + // it from the trade-critical commit path. + engine.commit(_ctxBuy()); + } + + function test_recipeVersionMismatch_failsClosed() public { + RecipeBinding[] memory bindings = _singleBinding(2, 2); + _registerBindings(bindings); + vm.expectRevert(abi.encodeWithSelector(Errors.RecipeVersionMismatch.selector, uint16(2), uint16(2), uint16(1))); + engine.evaluate(_ctxBuy()); + } + + function test_oversizedRecipeElementSet_failsClosedWithoutTruncation() public { + bytes32[] memory elements = new bytes32[](engine.MAX_ELEMENTS_PER_RECIPE() + 1); + for (uint256 i = 0; i < elements.length; i++) { + elements[i] = bytes32(i + 1); + } + recipeReg.registerRecipe(3, 1, address(new UnregisteredElementRecipe(elements))); + _registerBindings(_singleBinding(3, 1)); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.TooManyRecipeElements.selector, uint16(3), elements.length, engine.MAX_ELEMENTS_PER_RECIPE() + ) + ); + engine.evaluate(_ctxBuy()); + } + function test_unknown_token_fails_closed() public { // RWA never registered → UNKNOWN on both sides → fail-closed. ComplianceDecision memory d = engine.evaluate(_ctxBuy()); @@ -270,7 +416,7 @@ contract EngineTest is Test { function test_suspended_fails_closed() public { // Reach SUSPENDED the legal way: register -> approve -> suspend. ManifestCore memory m = _activeManifest(0, 0); - policyReg.registerManifest(RWA, m); + policyReg.registerManifest(RWA, m, _bindings(0)); policyReg.approveManifest(RWA); policyReg.suspendManifest(RWA, bytes32("HALT")); accredited.setAccredited(BUYER, true); @@ -289,7 +435,7 @@ contract EngineTest is Test { function test_proposed_against_unregulated_fails_closed() public { // RWA registered (PROPOSED) but NOT approved; CASH UNREGULATED. - policyReg.registerManifest(RWA, _activeManifest(0, 0)); + policyReg.registerManifest(RWA, _activeManifest(0, 0), _bindings(0)); _registerCashUnregulated(); _makeBuyerCompliant(); @@ -300,7 +446,7 @@ contract EngineTest is Test { function test_retired_against_unregulated_fails_closed() public { // RWA reaches RETIRED via register -> approve -> retire; CASH UNREGULATED. - policyReg.registerManifest(RWA, _activeManifest(0, 0)); + policyReg.registerManifest(RWA, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA); policyReg.retireManifest(RWA, bytes32("EOL")); _registerCashUnregulated(); @@ -314,8 +460,8 @@ contract EngineTest is Test { function test_proposed_against_active_fails_closed() public { // tokenIn RWA is only PROPOSED; tokenOut RWA2 is ACTIVE. The ACTIVE side // passing its recipes must NOT rescue a PROPOSED counterparty. - policyReg.registerManifest(RWA, _activeManifest(0, 0)); // PROPOSED - policyReg.registerManifest(RWA2, _activeManifest(0, 0)); + policyReg.registerManifest(RWA, _activeManifest(0, 0), _bindings(0)); // PROPOSED + policyReg.registerManifest(RWA2, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA2); // ACTIVE _makeBuyerCompliant(); @@ -326,10 +472,10 @@ contract EngineTest is Test { function test_retired_against_active_fails_closed_both_orderings() public { // RETIRED as tokenIn (RWA) against ACTIVE tokenOut (RWA2). - policyReg.registerManifest(RWA, _activeManifest(0, 0)); + policyReg.registerManifest(RWA, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA); policyReg.retireManifest(RWA, bytes32("EOL")); // RWA RETIRED - policyReg.registerManifest(RWA2, _activeManifest(0, 0)); + policyReg.registerManifest(RWA2, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA2); // RWA2 ACTIVE _makeBuyerCompliant(); @@ -363,8 +509,7 @@ contract EngineTest is Test { recipeReg.registerRecipe(3, 1, address(bad)); ManifestCore memory m = _activeManifest(0, 0); - m.issuanceRecipeId = 3; // point issuance at the bad recipe - policyReg.registerManifest(RWA, m); + policyReg.registerManifest(RWA, m, _singleBinding(3, 1)); policyReg.approveManifest(RWA); _registerCashUnregulated(); @@ -374,8 +519,7 @@ contract EngineTest is Test { function test_missing_issuance_recipe_reverts() public { ManifestCore memory m = _activeManifest(0, 0); - m.issuanceRecipeId = 77; // manifest points at a recipe that was never registered - policyReg.registerManifest(RWA, m); + policyReg.registerManifest(RWA, m, _singleBinding(77, 1)); policyReg.approveManifest(RWA); _registerCashUnregulated(); @@ -387,9 +531,9 @@ contract EngineTest is Test { // tokenIn requires RegD + Fund3c7 (QP). tokenOut requires only RegD. // Old single-side selection could choose tokenOut and incorrectly allow // without QP. The pair-level rule must reject until both sides pass. - policyReg.registerManifest(RWA, _activeManifest(2, 1)); + policyReg.registerManifest(RWA, _activeManifest(2, 1), _bindings(2)); policyReg.approveManifest(RWA); - policyReg.registerManifest(RWA2, _activeManifest(0, 0)); + policyReg.registerManifest(RWA2, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA2); _makeBuyerCompliant(); @@ -410,8 +554,7 @@ contract EngineTest is Test { recipeReg.registerRecipe(4, 1, address(surveilRecipe)); ManifestCore memory m = _activeManifest(0, 0); - m.issuanceRecipeId = 4; - policyReg.registerManifest(RWA, m); + policyReg.registerManifest(RWA, m, _singleBinding(4, 1)); policyReg.approveManifest(RWA); _registerCashUnregulated(); @@ -427,6 +570,40 @@ contract EngineTest is Test { assertEq(surveillance.transferCount(), 1); } + function test_commit_pathOption_updatesOnlyDeterministicallySelectedPath() public { + SurveillanceFlag lowerPriority = new SurveillanceFlag(); + SurveillanceFlag higherPriority = new SurveillanceFlag(); + lowerPriority.setEngine(address(engine)); + higherPriority.setEngine(address(engine)); + elementReg.registerElement(bytes32("F-PATH-A"), address(lowerPriority)); + elementReg.registerElement(bytes32("F-PATH-B"), address(higherPriority)); + _registerSingleElementRecipe(3, bytes32("F-PATH-A")); + _registerSingleElementRecipe(4, bytes32("F-PATH-B")); + + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.PATH_OPTION, 1, 10); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.PATH_OPTION, 1, 20); + _registerBindings(bindings); + + assertTrue(engine.evaluate(_ctxBuy()).allowed); + engine.commit(_ctxBuy()); + assertEq(lowerPriority.transferCount(), 0, "unselected path must not commit"); + assertEq(higherPriority.transferCount(), 1, "highest-priority passing path commits"); + } + + function test_commit_deduplicatesStatefulElementAcrossBindings() public { + _registerSingleElementRecipe(3, bytes32("F-02-v1")); + _registerSingleElementRecipe(4, bytes32("F-02-v1")); + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(3, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(4, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 10); + _registerBindings(bindings); + + assertTrue(engine.evaluate(_ctxBuy()).allowed); + engine.commit(_ctxBuy()); + assertEq(surveillance.transferCount(), 1, "same stateful element commits once per asset side"); + } + // Auth: a non-router caller cannot drive the post-trade write path. The // engine only accepts commit from the wired router (here the test contract); // any stranger must revert NotAuthorized. @@ -495,7 +672,7 @@ contract EngineTest is Test { // pass merely because its recipes would pass — the UNKNOWN counterparty // makes the pair fail-closed before any recipe runs. function test_active_against_unknown_cash_fails_closed() public { - policyReg.registerManifest(RWA, _activeManifest(0, 0)); + policyReg.registerManifest(RWA, _activeManifest(0, 0), _bindings(0)); policyReg.approveManifest(RWA); // CASH intentionally NOT registered → UNKNOWN. accredited.setAccredited(BUYER, true); // would otherwise satisfy RegD506c. @@ -524,8 +701,7 @@ contract EngineTest is Test { recipeReg.registerRecipe(6, 1, address(lockupRecipe)); ManifestCore memory m = _activeManifest(0, 0); - m.issuanceRecipeId = 6; // point issuance at the lockup-only recipe - policyReg.registerManifest(RWA, m); + policyReg.registerManifest(RWA, m, _singleBinding(6, 1)); policyReg.approveManifest(RWA); _registerCashUnregulated(); @@ -542,16 +718,49 @@ contract EngineTest is Test { } } +contract RevertingStatefulElement is IStatefulElement { + function check(address, address, address, uint256, bytes calldata) + external + pure + override + returns (bool passed, bytes32 reasonCode) + { + return (false, bytes32("FLAGGED")); + } + + function elementMetadata() external pure override returns (ElementMetadata memory) { + return ElementMetadata({ + elementId: bytes32("F-REVERT"), + category: ElementCategory.CONDUCT_MONITORING, + version: "F-REVERT-v1", + temporal: TemporalNature.CUMULATIVE, + decidability: Decidability.MONITORING_BASED, + timing: ObligationTiming.EX_POST_TRIGGER, + statefulness: Statefulness.STATEFUL + }); + } + + function onTransfer(address, address, uint256) external pure override { + revert("FLAG_ONLY_HOOK_MUST_NOT_RUN"); + } +} + /// @dev Test-only settable acquisition-time source for the Lockup element seam. contract MockAcquisitionSource is IAcquisitionSource { - mapping(bytes32 => uint64) internal _acquiredAt; + mapping(bytes32 => AcquisitionSnapshot) internal _snapshots; function setAcquiredAt(address holder, address asset, uint64 ts) external { - _acquiredAt[keccak256(abi.encode(holder, asset))] = ts; + _snapshots[keccak256(abi.encode(holder, asset))] = AcquisitionSnapshot({ + clockStart: ts, + observedAt: uint64(block.timestamp), + expiresAt: type(uint64).max, + sourceRef: keccak256("engine-fixture"), + status: AcquisitionStatus.VALID + }); } - function acquiredAt(address holder, address asset) external view override returns (uint64) { - return _acquiredAt[keccak256(abi.encode(holder, asset))]; + function acquisitionOf(address holder, address asset) external view override returns (AcquisitionSnapshot memory) { + return _snapshots[keccak256(abi.encode(holder, asset))]; } } diff --git a/test/unit/execution/AMMAdapter.t.sol b/test/unit/execution/AMMAdapter.t.sol index 1ed2c4d..aad10fb 100644 --- a/test/unit/execution/AMMAdapter.t.sol +++ b/test/unit/execution/AMMAdapter.t.sol @@ -100,6 +100,24 @@ contract AMMAdapterTest is Test { adapter.uniswapV3SwapCallback(int256(100 ether), -int256(100 ether), data); } + function test_revert_requestTokensDoNotMatchPoolDirection() public { + ExecutionRequest memory req = _req(""); + req.context.tokenIn = address(token1); + req.context.tokenOut = address(token0); + + vm.prank(ROUTER); + vm.expectRevert(Errors.AMMPoolTokenMismatch.selector); + adapter.execute(req, _emptyDecision); + } + + function test_revert_registeredPoolCallbackTokenDoesNotMatchPositiveDelta() public { + bytes memory data = abi.encode(BUYER, address(token1)); + + vm.prank(address(pool)); + vm.expectRevert(Errors.AMMPoolTokenMismatch.selector); + adapter.uniswapV3SwapCallback(int256(100 ether), -int256(100 ether), data); + } + function test_setPool_onlyOwner() public { vm.prank(address(0xA11CE)); vm.expectRevert("Ownable: caller is not the owner"); diff --git a/test/unit/execution/Router.t.sol b/test/unit/execution/Router.t.sol index d30f780..5ce8cc3 100644 --- a/test/unit/execution/Router.t.sol +++ b/test/unit/execution/Router.t.sol @@ -115,6 +115,19 @@ contract RouterTest is Test { router.execute(req); } + function test_execute_emitsNonBlockingComplianceFlags() public { + ComplianceDecision memory decision = + _decision(true, 1 << uint256(VenueType.AMM), bytes32(0), type(uint256).max, REASON_OK); + decision.decisionHash = keccak256("flagged-decision"); + decision.flagsBitmap = 5; + engine.setDecision(decision); + + vm.expectEmit(true, false, false, true); + emit Events.ComplianceFlags(decision.decisionHash, decision.flagsBitmap); + vm.prank(BUYER); + router.execute(_defaultReq()); + } + // ---- gate reverts ---- function test_revert_complianceRejected() public { @@ -178,6 +191,31 @@ contract RouterTest is Test { vm.prank(BUYER); vm.expectRevert(Errors.VenueSuspended.selector); router.execute(_defaultReq()); + assertFalse(router.usedNonce(BUYER, 1)); + } + + function test_revert_globalPaused_withoutConsumingNonce() public { + operatorReg.setGlobalPaused(true, bytes32("kill")); + vm.prank(BUYER); + vm.expectRevert(Errors.GlobalPaused.selector); + router.execute(_defaultReq()); + assertFalse(router.usedNonce(BUYER, 1)); + } + + function test_revert_tokenInPaused() public { + operatorReg.setAssetSuspended(TOKEN_IN, true, bytes32("kill")); + vm.prank(BUYER); + vm.expectRevert(Errors.TokenInPaused.selector); + router.execute(_defaultReq()); + assertFalse(router.usedNonce(BUYER, 1)); + } + + function test_revert_tokenOutPaused() public { + operatorReg.setAssetSuspended(TOKEN_OUT, true, bytes32("kill")); + vm.prank(BUYER); + vm.expectRevert(Errors.TokenOutPaused.selector); + router.execute(_defaultReq()); + assertFalse(router.usedNonce(BUYER, 1)); } function test_revert_venueNotAllowed_typeMaskMiss() public { diff --git a/test/unit/factory/Factory.t.sol b/test/unit/factory/Factory.t.sol index 04c50de..51d6759 100644 --- a/test/unit/factory/Factory.t.sol +++ b/test/unit/factory/Factory.t.sol @@ -9,7 +9,13 @@ import {TokenPolicyRegistry} from "../../../src/registry/TokenPolicyRegistry.sol import {VenueRegistry} from "../../../src/execution/VenueRegistry.sol"; import {ITokenPolicyRegistry} from "../../../src/interfaces/compliance/ITokenPolicyRegistry.sol"; import {IVenueRegistry} from "../../../src/interfaces/execution/IVenueRegistry.sol"; -import {ManifestCore, PolicyStatus, VenueType} from "../../../src/types/ComplianceTypes.sol"; +import { + ManifestCore, + PolicyStatus, + RecipeBinding, + RecipeBindingMode, + VenueType +} from "../../../src/types/ComplianceTypes.sol"; import {VenueConfig, CustodyModel} from "../../../src/types/VenueTypes.sol"; contract FactoryTest is Test { @@ -27,17 +33,21 @@ contract FactoryTest is Test { factory = new CornerStoreFactory(ITokenPolicyRegistry(address(tpr)), IVenueRegistry(address(vr))); // factory must own both registries to write to them + tpr.setOperator(address(this), true); tpr.transferOwnership(address(factory)); vr.transferOwnership(address(factory)); } function _manifest() internal view returns (ManifestCore memory m) { m.status = PolicyStatus.ACTIVE; - m.issuanceRecipeId = 506; - m.issuanceRecipeVersion = 1; m.declaredBy = address(this); } + function _bindings() internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(506, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + } + function _venueCfg() internal view returns (VenueConfig memory c) { c.venueType = VenueType.AMM; c.adapter = adapter; @@ -47,17 +57,17 @@ contract FactoryTest is Test { } function test_registerRWAToken_registersManifest() public { - factory.registerRWAToken(rwa, _manifest(), venue, _venueCfg()); + factory.registerRWAToken(rwa, _manifest(), _bindings(), venue, _venueCfg()); ManifestCore memory stored = tpr.manifestOf(rwa); assertEq(uint8(stored.status), uint8(PolicyStatus.ACTIVE)); - assertEq(stored.issuanceRecipeId, 506); + assertEq(tpr.recipeBindingsOf(rwa)[0].recipeId, 506); // registerManifest records the caller (the factory) as declaredBy. assertEq(stored.declaredBy, address(factory)); } function test_registerRWAToken_registersVenue() public { - factory.registerRWAToken(rwa, _manifest(), venue, _venueCfg()); + factory.registerRWAToken(rwa, _manifest(), _bindings(), venue, _venueCfg()); VenueConfig memory stored = vr.venueOf(venue); assertEq(uint8(stored.venueType), uint8(VenueType.AMM)); @@ -69,7 +79,61 @@ contract FactoryTest is Test { function test_registerRWAToken_onlyOperator() public { vm.prank(address(0xBEEF)); vm.expectRevert(); - factory.registerRWAToken(rwa, _manifest(), venue, _venueCfg()); + factory.registerRWAToken(rwa, _manifest(), _bindings(), venue, _venueCfg()); + } + + function test_scheduleManifestResume_forwardsRegistryOwnerCall() public { + factory.registerRWAToken(rwa, _manifest(), _bindings(), venue, _venueCfg()); + tpr.suspendManifest(rwa, bytes32("SUSPENDED")); + + factory.scheduleManifestResume(rwa, bytes32("RECOVERED")); + + (uint64 effectiveTime, bytes32 reasonCode) = tpr.pendingManifestResumeOf(rwa); + assertEq(effectiveTime, block.timestamp + tpr.MIN_MANIFEST_DELAY()); + assertEq(reasonCode, bytes32("RECOVERED")); + } + + function test_scheduleManifestResume_onlyFactoryOwner() public { + factory.registerRWAToken(rwa, _manifest(), _bindings(), venue, _venueCfg()); + tpr.suspendManifest(rwa, bytes32("SUSPENDED")); + + vm.prank(address(0xBEEF)); + vm.expectRevert("Ownable: caller is not the owner"); + factory.scheduleManifestResume(rwa, bytes32("RECOVERED")); + } + + function test_scheduleManifestUpdate_forwardsRegistryOwnerCall() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + factory.registerRWAToken(rwa, initial, _bindings(), venue, _venueCfg()); + + ManifestCore memory next = initial; + next.fullManifestHash = keccak256("manifest-v2"); + factory.scheduleManifestUpdate(rwa, next, _bindings(), bytes32("LEGAL-UPDATE")); + + (ManifestCore memory pending,, uint64 effectiveTime, bytes32 reasonCode) = tpr.pendingManifestUpdateOf(rwa); + assertEq(pending.fullManifestHash, next.fullManifestHash); + assertEq(effectiveTime, block.timestamp + tpr.MIN_MANIFEST_DELAY()); + assertEq(reasonCode, bytes32("LEGAL-UPDATE")); + } + + function test_cancelManifestActions_forwardsRegistryOwnerCalls() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + factory.registerRWAToken(rwa, initial, _bindings(), venue, _venueCfg()); + tpr.suspendManifest(rwa, bytes32("SUSPENDED")); + + factory.scheduleManifestResume(rwa, bytes32("RECOVERED")); + factory.cancelManifestResume(rwa); + (uint64 resumeTime,) = tpr.pendingManifestResumeOf(rwa); + assertEq(resumeTime, 0); + + ManifestCore memory next = initial; + next.fullManifestHash = keccak256("manifest-v2"); + factory.scheduleManifestUpdate(rwa, next, _bindings(), bytes32("LEGAL-UPDATE")); + factory.cancelManifestUpdate(rwa); + (,, uint64 updateTime,) = tpr.pendingManifestUpdateOf(rwa); + assertEq(updateTime, 0); } function test_computePoolAddress_isDeterministic() public view { diff --git a/test/unit/registry/OperatorRegistry.t.sol b/test/unit/registry/OperatorRegistry.t.sol index 6b8ea28..2982baf 100644 --- a/test/unit/registry/OperatorRegistry.t.sol +++ b/test/unit/registry/OperatorRegistry.t.sol @@ -31,12 +31,99 @@ contract OperatorRegistryTest is Test { assertTrue(reg.isVenueSuspended(venue)); } - function test_unsuspend() public { + function test_unsuspend_requiresOwnerScheduleAndDelay() public { + vm.prank(operator); + reg.setVenueSuspended(venue, true, bytes32("HALT")); + reg.scheduleVenueUnpause(venue, bytes32("RECOVERED")); + vm.expectPartialRevert(Errors.TimelockNotReady.selector); + reg.executeVenueUnpause(venue); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + reg.executeVenueUnpause(venue); + assertFalse(reg.isVenueSuspended(venue)); + } + + function test_globalUnpause_requiresOwnerScheduleAndDelay() public { + vm.prank(operator); + reg.setGlobalPaused(true, bytes32("HALT")); + reg.scheduleGlobalUnpause(bytes32("RECOVERED")); + vm.expectPartialRevert(Errors.TimelockNotReady.selector); + reg.executeGlobalUnpause(); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + reg.executeGlobalUnpause(); + assertFalse(reg.isGlobalPaused()); + } + + function test_assetUnpause_requiresOwnerScheduleAndDelay() public { + address asset = address(0xA55E7); + vm.prank(operator); + reg.setAssetSuspended(asset, true, bytes32("HALT")); + reg.scheduleAssetUnpause(asset, bytes32("RECOVERED")); + vm.expectPartialRevert(Errors.TimelockNotReady.selector); + reg.executeAssetUnpause(asset); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + reg.executeAssetUnpause(asset); + assertFalse(reg.isAssetSuspended(asset)); + } + + function test_unpauseSchedule_revertsForNonOwner() public { + vm.prank(operator); + reg.setGlobalPaused(true, bytes32("HALT")); + vm.prank(operator); + vm.expectRevert("Ownable: caller is not the owner"); + reg.scheduleGlobalUnpause(bytes32("RECOVERED")); + } + + function test_operatorCannotBypassVenueUnpauseTimelock() public { vm.startPrank(operator); reg.setVenueSuspended(venue, true, bytes32("HALT")); - reg.setVenueSuspended(venue, false, bytes32(0)); + vm.expectRevert(Errors.NotAuthorized.selector); + reg.setVenueSuspended(venue, false, bytes32("RECOVERED")); vm.stopPrank(); - assertFalse(reg.isVenueSuspended(venue)); + assertTrue(reg.isVenueSuspended(venue)); + } + + function test_globalAndAssetPauseReadbacks() public { + vm.startPrank(operator); + reg.setGlobalPaused(true, bytes32("GLOBAL")); + reg.setAssetSuspended(address(0xA55E7), true, bytes32("ASSET")); + vm.stopPrank(); + assertTrue(reg.isGlobalPaused()); + assertTrue(reg.isAssetSuspended(address(0xA55E7))); + assertNotEq(reg.pauseHistoryHash(), bytes32(0)); + } + + function test_repauseCancelsPendingUnpause() public { + vm.prank(operator); + reg.setVenueSuspended(venue, true, bytes32("HALT")); + reg.scheduleVenueUnpause(venue, bytes32("RECOVERED")); + vm.prank(operator); + reg.setVenueSuspended(venue, true, bytes32("NEW_INCIDENT")); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.executeVenueUnpause(venue); + } + + function test_globalRePauseCancelsPendingUnpause() public { + vm.prank(operator); + reg.setGlobalPaused(true, bytes32("HALT")); + reg.scheduleGlobalUnpause(bytes32("RECOVERED")); + vm.prank(operator); + reg.setGlobalPaused(true, bytes32("NEW_INCIDENT")); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.executeGlobalUnpause(); + } + + function test_assetRePauseCancelsPendingUnpause() public { + address asset = address(0xA55E7); + vm.prank(operator); + reg.setAssetSuspended(asset, true, bytes32("HALT")); + reg.scheduleAssetUnpause(asset, bytes32("RECOVERED")); + vm.prank(operator); + reg.setAssetSuspended(asset, true, bytes32("NEW_INCIDENT")); + vm.warp(block.timestamp + reg.MIN_UNPAUSE_DELAY()); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.executeAssetUnpause(asset); } function test_setVenueSuspended_reverts_for_non_operator() public { diff --git a/test/unit/registry/TokenPolicyRegistry.t.sol b/test/unit/registry/TokenPolicyRegistry.t.sol index a1f1c7a..0f1da72 100644 --- a/test/unit/registry/TokenPolicyRegistry.t.sol +++ b/test/unit/registry/TokenPolicyRegistry.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.17; import {Test} from "forge-std/Test.sol"; import {TokenPolicyRegistry} from "../../../src/registry/TokenPolicyRegistry.sol"; -import {ManifestCore, PolicyStatus} from "../../../src/types/ComplianceTypes.sol"; +import {ManifestCore, PolicyStatus, RecipeBinding, RecipeBindingMode} from "../../../src/types/ComplianceTypes.sol"; import {Errors} from "../../../src/libraries/Errors.sol"; import {Events} from "../../../src/libraries/Events.sol"; @@ -29,6 +29,11 @@ contract TokenPolicyRegistryTest is Test { m.declaredBy = owner; } + function _bindings() internal pure returns (RecipeBinding[] memory bindings) { + bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(7, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + } + /// @dev Drive a token to ACTIVE the legal way (register -> approve). function _activate(address t) internal { reg.registerManifest(t, _manifest()); @@ -47,7 +52,7 @@ contract TokenPolicyRegistryTest is Test { function test_register_lands_PROPOSED_ignoring_caller_status() public { ManifestCore memory m = _manifest(); // m.status == ACTIVE on purpose vm.expectEmit(true, false, false, true); - emit Events.ManifestRegistered(token, m.issuanceRecipeId, owner); + emit Events.ManifestRegistered(token, keccak256(abi.encode(_bindings())), owner); reg.registerManifest(token, m); ManifestCore memory got = reg.manifestOf(token); @@ -57,6 +62,8 @@ contract TokenPolicyRegistryTest is Test { assertEq(got.declaredBy, owner, "declaredBy = register caller"); assertEq(got.approvedBy, address(0), "not yet approved"); assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.PROPOSED)); + assertEq(reg.manifestVersionOf(token), 1, "first semantic version"); + assertNotEq(reg.manifestHistoryHashOf(token), bytes32(0), "history anchored"); } function test_register_records_caller_as_declaredBy() public { @@ -85,13 +92,51 @@ contract TokenPolicyRegistryTest is Test { assertEq(reg.manifestOf(token).approvedBy, operator, "approvedBy = approve caller"); } - function test_approve_reverts_when_recipe_set_empty() public { - ManifestCore memory m = _manifest(); - m.issuanceRecipeId = 0; // empty recipe set -> not approvable - reg.registerManifest(token, m); - vm.prank(operator); - vm.expectRevert(abi.encodeWithSelector(Errors.RecipeNotRegistered.selector, uint16(0))); - reg.approveManifest(token); + function test_register_reverts_when_recipe_set_empty() public { + RecipeBinding[] memory bindings = new RecipeBinding[](0); + vm.expectRevert(Errors.InvalidRecipeBinding.selector); + reg.registerManifest(token, _manifest(), bindings); + } + + function test_register_rejects_oversized_binding_plan() public { + RecipeBinding[] memory bindings = new RecipeBinding[](reg.MAX_RECIPE_BINDINGS() + 1); + for (uint256 i = 0; i < bindings.length; i++) { + bindings[i] = + RecipeBinding(uint16(i + 1), 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, uint8(bindings.length - i)); + } + vm.expectRevert( + abi.encodeWithSelector(Errors.TooManyRecipeBindings.selector, bindings.length, reg.MAX_RECIPE_BINDINGS()) + ); + reg.registerManifest(token, _manifest(), bindings); + } + + function test_register_rejects_duplicate_recipe_binding() public { + RecipeBinding[] memory bindings = new RecipeBinding[](2); + bindings[0] = RecipeBinding(7, 1, RecipeBindingMode.REQUIRED_BLOCKING, 0, 100); + bindings[1] = RecipeBinding(7, 1, RecipeBindingMode.FLAG_ONLY, 0, 10); + vm.expectRevert(abi.encodeWithSelector(Errors.DuplicateRecipeBinding.selector, uint16(7))); + reg.registerManifest(token, _manifest(), bindings); + } + + function test_register_rejects_path_without_group() public { + RecipeBinding[] memory bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(7, 1, RecipeBindingMode.PATH_OPTION, 0, 100); + vm.expectRevert(Errors.InvalidRecipeBinding.selector); + reg.registerManifest(token, _manifest(), bindings); + } + + function test_register_rejects_flagOnly_plan_without_blocking_gate() public { + RecipeBinding[] memory bindings = new RecipeBinding[](1); + bindings[0] = RecipeBinding(7, 1, RecipeBindingMode.FLAG_ONLY, 0, 10); + vm.expectRevert(Errors.InvalidRecipeBinding.selector); + reg.registerManifest(token, _manifest(), bindings); + } + + function test_register_stores_recipeBindings() public { + RecipeBinding[] memory expected = _bindings(); + reg.registerManifest(token, _manifest(), expected); + RecipeBinding[] memory actual = reg.recipeBindingsOf(token); + assertEq(keccak256(abi.encode(actual)), keccak256(abi.encode(expected))); } function test_approve_reverts_for_non_operator() public { @@ -115,15 +160,33 @@ contract TokenPolicyRegistryTest is Test { function test_resume_SUSPENDED_to_ACTIVE() public { _activate(token); - vm.startPrank(operator); + vm.prank(operator); reg.suspendManifest(token, bytes32("HALT")); + reg.scheduleManifestResume(token, bytes32("RECOVERED")); + vm.warp(block.timestamp + reg.MIN_MANIFEST_DELAY()); + vm.startPrank(operator); vm.expectEmit(true, false, false, true); - emit Events.ManifestStatusChanged(token, PolicyStatus.ACTIVE, bytes32(0)); + emit Events.ManifestStatusChanged(token, PolicyStatus.ACTIVE, bytes32("RECOVERED")); reg.resumeManifest(token); vm.stopPrank(); assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.ACTIVE)); } + function test_resume_requiresScheduleAndDelay() public { + _activate(token); + vm.prank(operator); + reg.suspendManifest(token, bytes32("HALT")); + + vm.prank(operator); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.resumeManifest(token); + + reg.scheduleManifestResume(token, bytes32("RECOVERED")); + vm.prank(operator); + vm.expectPartialRevert(Errors.TimelockNotReady.selector); + reg.resumeManifest(token); + } + function test_suspend_reverts_for_non_operator() public { _activate(token); vm.prank(stranger); @@ -140,6 +203,28 @@ contract TokenPolicyRegistryTest is Test { reg.resumeManifest(token); } + function test_resume_schedule_reverts_for_non_owner() public { + _activate(token); + vm.prank(operator); + reg.suspendManifest(token, bytes32("HALT")); + vm.prank(operator); + vm.expectRevert("Ownable: caller is not the owner"); + reg.scheduleManifestResume(token, bytes32("RECOVERED")); + } + + function test_resume_cancelInvalidatesPendingResume() public { + _activate(token); + vm.prank(operator); + reg.suspendManifest(token, bytes32("HALT")); + reg.scheduleManifestResume(token, bytes32("RECOVERED")); + reg.cancelManifestResume(token); + vm.warp(block.timestamp + reg.MIN_MANIFEST_DELAY()); + vm.prank(operator); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.resumeManifest(token); + assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.SUSPENDED)); + } + // --- retire ----------------------------------------------------------- function test_retire_from_ACTIVE() public { @@ -178,6 +263,7 @@ contract TokenPolicyRegistryTest is Test { reg.registerManifest(token, _manifest()); assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.PROPOSED)); assertEq(reg.manifestOf(token).approvedBy, address(0), "approver cleared on re-register"); + assertEq(reg.manifestVersionOf(token), 2, "reissue increments semantic version"); } function test_reregister_from_PROPOSED_reverts() public { @@ -399,4 +485,133 @@ contract TokenPolicyRegistryTest is Test { vm.expectRevert(Errors.NotAuthorized.selector); reg.setFact(token, 0x0F); } + + // --- delayed semantic update ----------------------------------------- + + function test_manifestUpdate_activatesAfterDelayAndIncrementsVersion() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + reg.registerManifest(token, initial); + vm.prank(operator); + reg.approveManifest(token); + bytes32 historyBefore = reg.manifestHistoryHashOf(token); + + ManifestCore memory next = initial; + next.issuanceRecipeVersion = 2; + next.fullManifestHash = keccak256("manifest-v2"); + reg.scheduleManifestUpdate(token, next, bytes32("REGULATORY_UPDATE")); + + (,, uint64 effectiveTime, bytes32 reasonCode) = reg.pendingManifestUpdateOf(token); + assertEq(reasonCode, bytes32("REGULATORY_UPDATE")); + vm.prank(operator); + vm.expectPartialRevert(Errors.TimelockNotReady.selector); + reg.activateManifestUpdate(token); + + vm.warp(effectiveTime); + vm.prank(operator); + reg.activateManifestUpdate(token); + + assertEq(reg.manifestVersionOf(token), 2); + assertEq(reg.manifestOf(token).fullManifestHash, next.fullManifestHash); + assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.ACTIVE)); + assertNotEq(reg.manifestHistoryHashOf(token), historyBefore); + } + + function test_manifestUpdate_changesBindingsOnlyAfterActivation() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + RecipeBinding[] memory oldBindings = _bindings(); + reg.registerManifest(token, initial, oldBindings); + vm.prank(operator); + reg.approveManifest(token); + + ManifestCore memory next = initial; + next.fullManifestHash = keccak256("manifest-v2"); + RecipeBinding[] memory nextBindings = new RecipeBinding[](2); + nextBindings[0] = oldBindings[0]; + nextBindings[1] = RecipeBinding(8, 2, RecipeBindingMode.FLAG_ONLY, 0, 10); + reg.scheduleManifestUpdate(token, next, nextBindings, bytes32("BINDING_UPDATE")); + + assertEq(keccak256(abi.encode(reg.recipeBindingsOf(token))), keccak256(abi.encode(oldBindings))); + (, RecipeBinding[] memory pending, uint64 effectiveTime,) = reg.pendingManifestUpdateOf(token); + assertEq(keccak256(abi.encode(pending)), keccak256(abi.encode(nextBindings))); + + vm.warp(effectiveTime); + vm.prank(operator); + reg.activateManifestUpdate(token); + assertEq(keccak256(abi.encode(reg.recipeBindingsOf(token))), keccak256(abi.encode(nextBindings))); + assertEq(reg.manifestVersionOf(token), 2); + } + + function test_manifestUpdate_preservesSuspendedState() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + reg.registerManifest(token, initial); + vm.startPrank(operator); + reg.approveManifest(token); + reg.suspendManifest(token, bytes32("INCIDENT")); + vm.stopPrank(); + + ManifestCore memory next = initial; + next.fullManifestHash = keccak256("manifest-v2"); + reg.scheduleManifestUpdate(token, next, bytes32("RECIPE_UPDATE")); + vm.warp(block.timestamp + reg.MIN_MANIFEST_DELAY()); + vm.prank(operator); + reg.activateManifestUpdate(token); + assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.SUSPENDED)); + } + + function test_manifestUpdate_rejectsMissingOrUnchangedHash() public { + _activate(token); + ManifestCore memory next = _manifest(); + vm.expectRevert(Errors.InvalidManifestHash.selector); + reg.scheduleManifestUpdate(token, next, bytes32("RECIPE_UPDATE")); + } + + function test_manifestUpdate_scheduleRevertsForNonOwner() public { + _activate(token); + ManifestCore memory next = _manifest(); + next.fullManifestHash = keccak256("manifest-v2"); + vm.prank(operator); + vm.expectRevert("Ownable: caller is not the owner"); + reg.scheduleManifestUpdate(token, next, bytes32("RECIPE_UPDATE")); + } + + function test_manifestUpdate_cancelPreservesManifestAndVersion() public { + ManifestCore memory initial = _manifest(); + initial.fullManifestHash = keccak256("manifest-v1"); + bytes32 initialHash = initial.fullManifestHash; + reg.registerManifest(token, initial); + vm.prank(operator); + reg.approveManifest(token); + + ManifestCore memory next = initial; + next.fullManifestHash = keccak256("manifest-v2"); + reg.scheduleManifestUpdate(token, next, bytes32("RECIPE_UPDATE")); + reg.cancelManifestUpdate(token); + vm.warp(block.timestamp + reg.MIN_MANIFEST_DELAY()); + vm.prank(operator); + vm.expectRevert(Errors.PendingActionNotFound.selector); + reg.activateManifestUpdate(token); + + assertEq(reg.manifestVersionOf(token), 1); + assertEq(reg.manifestOf(token).fullManifestHash, initialHash); + assertEq(uint256(reg.statusOf(token)), uint256(PolicyStatus.ACTIVE)); + } + + function test_setFact_revertsWhenActive_toPreventTimelockBypass() public { + _activate(token); + vm.prank(operator); + vm.expectRevert(Errors.InvalidManifestTransition.selector); + reg.setFact(token, 0x0F); + } + + function test_setFact_revertsWhenRetired_toPreserveHistory() public { + _activate(token); + vm.prank(operator); + reg.retireManifest(token, bytes32("EOL")); + vm.prank(operator); + vm.expectRevert(Errors.InvalidManifestTransition.selector); + reg.setFact(token, 0x0F); + } } diff --git a/tools/deploy-v3/CORNER_STORE_PROFILE.md b/tools/deploy-v3/CORNER_STORE_PROFILE.md index 032c1e9..37cc39a 100644 --- a/tools/deploy-v3/CORNER_STORE_PROFILE.md +++ b/tools/deploy-v3/CORNER_STORE_PROFILE.md @@ -218,8 +218,10 @@ The profile has been verified on Anvil to: - omit the migrator, staker, and `SwapRouter02`. The automated unit test locks the profile contents and dependency order. The -Anvil run performed during profile extraction was a deployment integration -check; it is not yet a committed automated integration test. +repository integration suite also deploys the pinned v3 core factory/pool +artifact and proves canonical CREATE2 addressing, liquidity callbacks and a +Router-protected ERC-3643 swap. A unified deploy-v3 + Corner Store deployment +command remains a separate orchestration task. ## Not Yet Provided @@ -228,8 +230,9 @@ check; it is not yet a committed automated integration test. - Deployment of Corner Store compliance, asset registry, routing, order-book, or RFQ contracts. - A unified deployment manifest across Uniswap and Corner Store contracts. -- Pool creation, liquidity provisioning, swaps, or end-to-end compliance - tests. +- Production pool creation/liquidity orchestration and a unified deployment + command. Canonical pool behavior is covered by the repository integration + fixture, not by this vendored CLI. - Production network configuration, contract verification, multisig handoff, or upgrade-governance procedures.