diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f4c0f8..d53aec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,13 @@ jobs: with: version: stable + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: services/rfq/package-lock.json + - name: Check formatting run: forge fmt --check @@ -33,3 +40,9 @@ jobs: - name: Run tests run: forge test -vv + + - name: Run RFQ service smoke + working-directory: services/rfq + run: | + npm ci + npm test diff --git a/.gitignore b/.gitignore index 8fcb2e8..0f27f46 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ out/ cache/ broadcast/ +services/rfq/dist/ +services/rfq/node_modules/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 541042e..085cca9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,10 +17,10 @@ Execution Integration Kit로 구성한다. Corner Store reference DEX는 이 공 | 경로 | 역할 | | --- | --- | -| `src/` | 제품 Solidity 컨트랙트. 현재 Counter scaffold 상태 | +| `src/` | 제품 Solidity 컨트랙트: Compliance Core, Execution Integration Kit, reference adapters | | `test/` | Foundry 단위·통합 테스트 | -| `script/` | Foundry 배포·운영 스크립트 | | `docs/` | 제품 명세, 아키텍처, 로드맵과 Harness 문서 | +| `services/rfq/` | RFQ v1 quote signer reference service | | `tools/deploy-v3/` | 독립적으로 유지하는 vendored Uniswap v3 배포 도구 | | `lib/` | Foundry 의존성 | | `scripts/` | 저장소 setup, 검증과 정리 명령 | @@ -84,6 +84,13 @@ SDK와 reference DEX의 전체 실행 흐름은 제품 명세에, 세부 책임 ## Where to Add New Code 제품 코드는 Compliance Core, Execution Integration Kit와 Corner Store reference -Adapter/configuration의 의존 방향이 드러나게 구성한다. 정확한 Solidity 디렉터리는 -Foundation feature에서 확정하며, 기존 Counter scaffold를 제품 구조로 간주하지 -않는다. +Adapter/configuration의 의존 방향이 드러나게 구성한다. + +- 공통 compliance interface/type/library는 `src/interfaces`, `src/types`, + `src/libraries`에 둔다. +- compliance 구현은 `src/compliance`와 `src/registry`에 둔다. +- Router/venue registry/selector와 공통 adapter interface는 `src/execution`에 둔다. +- 구체 reference venue adapter는 `src/execution/adapters//`에 둔다. +- RFQ offchain quote signer reference는 `services/rfq`에 둔다. +- production dealer, custody, matching, pricing engine은 별도 decision/feature 없이 + reference adapter 내부에 섞지 않는다. diff --git a/FEATURES.md b/FEATURES.md index cb3e51a..980a7c9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -65,7 +65,7 @@ passing ### Behavior -- Counter template를 제품 개발 구조로 교체한다. +- Foundry template를 제품 개발 구조로 교체한다. - 제품 interface, type, error와 mock fixture를 컴파일할 수 있다. - 이후 compliance와 execution feature가 재사용할 테스트 기반을 제공한다. @@ -77,8 +77,41 @@ passing ### State -not-started +passing + +### Notes + +- 현재 제품 구조는 Compliance Core, Execution Integration Kit, reference adapters와 + Foundry unit/integration fixture를 포함한다. +- production Manifest lifecycle, RFQ dealer/custody, OrderBook은 별도 feature다. + +## RFQ-001 — Reference RFQ Settlement + +### Behavior + +- RFQ가 AMM과 같은 `ExecutionRouter`/Adapter slot에 등록·교체될 수 있다. +- RFQ quote는 maker가 EIP-712로 서명하고 chainId, RFQAdapter, maker, taker, + token, amount, venue, nonce, expiry에 바인딩된다. +- RFQAdapter는 Router-only로 동작하고 direct adapter bypass를 거부한다. +- 매 fill은 Router의 최신 compliance evaluation 이후 full-fill/exact-taker로만 + settlement된다. +- reference TypeScript service는 quote 생성, expiry/nonce 부여, EIP-712 signing + 요청만 담당한다. + +### Verification + +- `forge fmt` +- `forge build` +- `forge test --offline --match-path test/unit/execution/RFQAdapter.t.sol -vv` +- `forge test --offline` +- `cd services/rfq && npm test` +- `git diff --check` + +### State + +passing ### Notes -- DOC-001에서 제품 구조와 구현 순서를 확정한 뒤 시작한다. +- Non-goals: partial fill, orderbook, production pricing engine, dealer inventory, + custody 확장, websocket/order discovery. diff --git a/PROGRESS.md b/PROGRESS.md index 367cbc5..21e0e47 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,9 +2,8 @@ ## Current Status -저장소는 SDK/reference DEX 아키텍처·개발 계획 문서, Foundry Counter scaffold와 -vendored Uniswap v3 배포 도구를 포함한다. 제품 Solidity 컨트랙트는 아직 구현되지 -않았다. +저장소는 SDK/reference DEX 아키텍처·개발 계획 문서, Foundry product scaffold, +reference execution contracts와 vendored Uniswap v3 배포 도구를 포함한다. 공식 문서는 DEX-level compliance SDK, Corner Store reference DEX, Element/Recipe/Manifest/Operator 4-Layer와 cumulative multi-Recipe 모델을 @@ -13,14 +12,16 @@ source of truth로 사용한다. ## Active Feature - 없음 -- 다음 작업은 `FND-001 — Foundry Product Foundation`이다. ## Completed - `HE-001 — Harness Baseline` - `DOC-001 — Imported Architecture Alignment` +- `FND-001 — Foundry Product Foundation` +- `RFQ-001 — Reference RFQ Settlement` - multi-venue 아키텍처와 책임 문서 작성 - Corner Store용 Uniswap v3 최소 배포 profile 분리와 테스트 +- ExecutionRouter/VenueRegistry/VenueSelector와 AMM reference adapter skeleton ## Blocked @@ -28,40 +29,37 @@ source of truth로 사용한다. ## Next -1. `FND-001 — Foundry Product Foundation`의 Exec Plan을 작성한다. -2. Compliance Core, Execution Integration Kit와 Corner Store reference Adapter의 - 디렉터리·의존 방향을 확정한다. -3. 공통 context, interface, error와 mock fixture를 구현한다. -4. stateful Element commit hook과 acquisition data source는 구현 전에 별도 결정한다. +1. RFQ production hardening은 별도 feature로 분리한다: dealer/operator approval, + custody, quote cancellation, partial fill 정책. +2. production Asset Compliance Manifest lifecycle/schema와 operator approval flow를 + 구현한다. +3. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다. +4. live Anvil deployment/E2E와 security threat model을 추가한다. +5. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다. ## Last Session Summary - 변경한 파일: - - 제품 baseline, root architecture와 README - - 4-Layer compliance, Asset Manifest, execution/venue 책임 문서 - - 구현 roadmap, decisions, security와 testing 기준 - - DOC-001 Exec Plan과 Harness 상태 문서 - - review에서 확인된 pair 평가, SDK/reference DEX 경계와 provenance 문제 수정 + - RFQAdapter, RFQQuote type, RFQ-specific errors + - RFQAdapter Foundry tests + - `services/rfq` 최소 TypeScript quote signer reference + - RFQ v1 scope/non-goals 문서 - 실행한 명령: - - current 문서 legacy 용어 검색 - - Markdown 로컬 링크 검사 + - `forge build` + - `forge test --offline --match-path test/unit/execution/RFQAdapter.t.sol -vv` + - `cd services/rfq && npm test` - `git diff --check` - `scripts/check.sh` - 통과한 검증: - - current 문서에서 이전 architecture terminology와 pending migration 표현 제거 - - 비커밋 입력 경로에 대한 current 문서 직접 의존 제거 - - 로컬 Markdown 경로 29개 파일 확인 - - policy plugin과 execution Adapter plugin 경계 교차 검토 - - mixed pair와 regulated-regulated pair의 양쪽 Manifest 평가 규칙 확인 - - `forge fmt --check` - - `forge build` - - Foundry Counter 테스트 2개 - - `tools/deploy-v3` 테스트 10개 - - `git diff --check` + - RFQAdapter compile + - valid signed quote settlement + - invalid signature, expired quote, replay, wrong taker, mismatch, direct bypass, + compliance rejection 거부 + - RFQ service typed-data/smoke check + - 전체 repo check 통과 - 남은 리스크: - - 제품 Solidity는 Counter template 상태다. - - CI, 제품 integration/E2E와 정적 분석이 아직 없다. - - acquisition data, stateful Element commit hook과 reject logging은 열린 결정이다. + - production dealer approval, custody, quote cancellation, partial fill은 RFQ v1 + 범위 밖이다. + - production Manifest lifecycle과 acquisition/lot source는 아직 결정·구현 전이다. + - live deployment/E2E와 static analysis는 아직 부족하다. - production Element와 engine 허용 조건은 법률 승인 전 활성화할 수 없다. - - concrete Adapter는 reference DEX 소유이고 generic Router/Adapter 경계는 SDK - 소유라는 구조를 구현 디렉터리에 반영해야 한다. diff --git a/QUALITY.md b/QUALITY.md index 37720c5..45d6a37 100644 --- a/QUALITY.md +++ b/QUALITY.md @@ -2,13 +2,14 @@ | Module | Grade | Reason | Required Improvement | | --- | --- | --- | --- | -| Product documentation | B | SDK/reference DEX, 4-Layer와 roadmap이 정합함 | 개별 Element·Manifest schema와 법률 승인 보강 | -| Harness / agent workflow | B | HE-001과 DOC-001 상태·검증 이력이 존재함 | CI 연계와 구현 feature 운영 검증 | -| Product Solidity | D | Counter template만 존재 | FND-001 제품 foundation 구현 | -| Foundry tests | D | Counter template test만 존재 | 제품 fixture와 behavior test 추가 | +| Product documentation | B | SDK/reference DEX, 4-Layer, RFQ v1 scope와 roadmap이 대체로 정합함 | production RFQ/OrderBook, Manifest lifecycle와 법률 승인 기준 보강 | +| 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 122개와 RFQ failure-path 테스트 존재 | live Anvil deployment/E2E와 추가 adversarial/security tests | +| RFQ reference service | B- | EIP-712 typed-data 생성, nonce/expiry, unsafe number guard와 smoke test 존재 | 실제 API 서버, signer custody, dealer pricing/inventory는 production feature에서 결정 | | `tools/deploy-v3` | B | profile 단위 테스트와 문서 존재 | 자동 Anvil integration test 추가 | -| CI / static analysis | D | CI와 정적 분석 설정 없음 | 향후 foundation feature에서 추가 | -| Security documentation | B | trust boundary와 구현 전 보안 규칙을 통합 문서화함 | 구현 및 위협 모델과 지속 동기화 | +| CI / static analysis | C | GitHub Actions가 Foundry와 RFQ service smoke를 실행함 | deploy-v3 CI, slither 등 정적 분석, warning budget 도입 | +| Security documentation | B | trust boundary, direct venue boundary와 구현 전 보안 규칙을 문서화함 | RFQ/dealer/custody 위협 모델과 production review 체크리스트 보강 | ## Grade Guide diff --git a/README.md b/README.md index 30541cd..00c393a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ are registered through a generic Router/Adapter boundary. Concrete Corner Store adapters and deployment configuration are reference implementations. The repository currently contains the architecture and development plan, a -vendored Uniswap v3 deployment tool, and the initial Foundry project scaffold. -Product Solidity contracts are not implemented yet. +vendored Uniswap v3 deployment tool, the Foundry product scaffold, and initial +reference execution adapters including AMM and RFQ settlement paths. ## Main Use Cases @@ -49,6 +49,7 @@ Product Solidity contracts are not implemented yet. - Contracts: Solidity + Foundry - Tests: Forge - Local chain: Anvil +- RFQ reference service: TypeScript - Vendored deployment tooling: TypeScript, Yarn, ethers v5 ## Local Setup @@ -56,7 +57,8 @@ Product Solidity contracts are not implemented yet. Required tools: - Foundry (`forge`, `anvil`) -- Node.js and Yarn for `tools/deploy-v3` +- Node.js and npm for `services/rfq` +- Yarn for `tools/deploy-v3` Install or refresh the vendored tool dependencies when needed: @@ -67,8 +69,9 @@ yarn install --frozen-lockfile ## Development Commands -The product contracts use Foundry. The template `Counter` files remain only -until Roadmap Phase 0 replaces them with the product structure and fixtures. +The product contracts use Foundry. The current scaffold contains the Compliance +Core, Execution Integration Kit, AMM reference adapter, RFQ v1 reference +settlement adapter, and related fixtures/tests. ### Build @@ -94,6 +97,20 @@ forge fmt anvil ``` +### RFQ Reference Service + +`services/rfq` is a minimal quote signer reference for RFQ v1. It builds the +same EIP-712 typed data that `RFQAdapter` verifies, assigns expiry and nonce, +and returns a signed quote. It is not a production dealer, pricing engine, +inventory manager, custody service, websocket feed, orderbook, or compliance +decision engine. + +```shell +cd services/rfq +npm ci +npm test +``` + ### Check All ```shell diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ea18f7..93ad5ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,16 +14,23 @@ - ERC-3643/ONCHAINID 외부 trust boundary 정의 - AMM, RFQ와 Order Book Adapter 경계 정의 - Uniswap v3 vendored deployment profile 분리와 단위 테스트 - -미구현: - -- 제품 Solidity contracts/interfaces -- mock ERC-3643/identity/compliance fixture -- Element/Recipe/Manifest registry와 evaluation -- generic ExecutionRouter/VenueRegistry와 공통 Adapter interface -- Corner Store reference Adapter -- 자동 integration/E2E -- CI와 static analysis +- Foundry product scaffold와 공통 type/error/event/interface +- Element/Recipe registry와 illustrative compliance elements/recipes +- Token policy registry와 cumulative multi-Recipe evaluation +- generic ExecutionRouter, VenueRegistry, VenueSelector와 공통 Adapter interface +- AMM reference adapter와 RFQ v1 reference settlement adapter +- RFQ quote signer reference service +- Foundry unit/integration tests, RFQ service smoke와 기본 GitHub Actions + +남은 주요 작업: + +- production Asset Compliance Manifest lifecycle/schema/proposal/activation +- production legal Element 기준과 승인된 operator 입력 모델 +- acquisition/lot data source와 holding-period Recipe 활성화 조건 +- RFQ dealer/operator approval, quote cancellation, custody와 partial-fill 정책 +- Order Book matching/custody/surveillance 모델 +- 자동 live Anvil deployment/E2E, monitoring, incident runbook +- static analysis, warning budget와 production security/legal review ## Delivery Strategy @@ -55,7 +62,9 @@ flowchart LR ### Goal -Foundry Counter template를 SDK와 reference integration 개발 기반으로 교체한다. +Foundry template를 SDK와 reference integration 개발 기반으로 교체한다. + +Status: implemented for the current reference proof. ### Deliverables @@ -85,6 +94,9 @@ Foundry Counter template를 SDK와 reference integration 개발 기반으로 교 Element와 Recipe의 portable interface, registry와 version semantics를 구현한다. +Status: implemented for illustrative/reference Elements and Recipes; production +legal criteria remain approval-gated. + ### Deliverables - `IElement`와 immutable/versioned Element reference @@ -121,6 +133,9 @@ 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. + ### Deliverables - `ManifestCore`와 Manifest registry/resolver @@ -163,6 +178,8 @@ acquisition data가 필요한 Recipe는 data source가 결정되기 전 활성 제3의 DEX도 재사용할 수 있는 generic Router와 Adapter 등록·dispatch 경계를 구현한다. +Status: implemented for the reference proof with AMM and RFQ adapters. + ### Deliverables - `ExecutionRouter` @@ -225,18 +242,22 @@ Blockers: ### 4B. RFQ +Status: v1 reference settlement implemented; production dealer and custody +decisions remain open. + Deliverables: - EIP-712 quote - signature, nonce, expiry와 taker binding -- partial fill policy -- RFQ Adapter와 latest compliance evaluation +- Router-only RFQ Adapter와 latest compliance evaluation +- 최소 TypeScript quote signer reference service +- partial fill policy는 v1 non-goal로 유지 Completion: - invalid signer, replay와 expired quote가 거부된다. - Manifest/Recipe 또는 operator 변경이 fill에 반영된다. -- total fill이 quote amount를 초과하지 않는다. +- signed quote와 request amount가 정확히 일치한다. Blockers: @@ -296,17 +317,16 @@ SDK와 reference DEX를 반복 배포하고 Manifest/권한 상태를 검증 가 ## Near-Term Issues -구현 전에 생성할 가까운 이슈: - -1. `chore: SDK와 reference DEX용 Foundry 제품 구조 구성` -2. `feat: compliance context와 Element/Recipe interface 정의` -3. `test: mock ERC-3643 identity와 adapter fixture 구성` -4. `design: stateful Element commit hook 결정` -5. `design: acquisition data source 결정` -6. `feat: Asset Compliance Manifest lifecycle 구현` -7. `feat: cumulative multi-Recipe evaluation 구현` -8. `feat: structured decision과 ExecutionRouter dispatch 구현` -9. `test: regulated/public path와 direct venue boundary E2E` +가까운 후속 이슈: + +1. `design(rfq): dealer/operator approval, custody, cancellation, partial-fill policy 확정` +2. `security(rfq): direct venue, signer, custody, replay, quote-cancellation threat model 작성` +3. `feat(compliance): production Asset Compliance Manifest lifecycle/schema 구현` +4. `design(compliance): acquisition/lot data source와 holding-period Recipe 활성화 조건 결정` +5. `test(e2e): live Anvil deployment와 regulated RFQ/AMM settlement smoke 자동화` +6. `feat(orderbook): matching/custody/surveillance 모델 결정 후 Order Book adapter 구현` +7. `chore(ci): deploy-v3 CI, static analysis, lint/warning budget 추가` +8. `docs(ops): Manifest activation/suspension runbook과 incident drill 문서화` ## Decision Backlog diff --git a/docs/architecture/SKELETON_GUIDE.md b/docs/architecture/SKELETON_GUIDE.md index 28b5e1f..8c17165 100644 --- a/docs/architecture/SKELETON_GUIDE.md +++ b/docs/architecture/SKELETON_GUIDE.md @@ -103,7 +103,8 @@ | ERC-3643 `isVerified`/`canTransfer`, OnchainID claim | **진짜 동작** (테스트에서 실제 T-REX 배포) | | Element의 법률 판정(적격투자자·제재·QP·lockup 등) | **mock** (설정 가능한 bool/주입값) | | Uniswap v3 실제 pool 수학 | **mock** (MockPool 1:1) | -| RFQ / OrderBook adapter | **스텁** (revert) | +| RFQ adapter | **v1 reference 동작** (Router-only, exact-taker full-fill EIP-712 quote settlement) | +| OrderBook adapter | **스텁** (revert) | | `computePoolAddress` | **스텁** (결정론적 keccak, 실제 init-code-hash 아님) | 요점: **"배관은 진짜로 흐르고, 법률 판단만 mock"**. 그래서 E2E 테스트로 전체 경로를 diff --git a/docs/architecture/venues/README.md b/docs/architecture/venues/README.md index dd7d312..710585d 100644 --- a/docs/architecture/venues/README.md +++ b/docs/architecture/venues/README.md @@ -69,25 +69,47 @@ Uniswap v3는 Corner Store의 첫 번째 AMM venue다. 전체 DEX가 아니라 RFQ는 기관, 대량 거래, 승인 상대방 거래를 위한 경로다. 견적 탐색과 협상은 오프체인, 검증과 settlement는 온체인으로 시작한다. +### RFQ v1 Scope + +현재 구현은 `ExecutionRouter` 뒤에 붙는 reference Adapter다. AMM과 같은 +Router/Adapter slot을 사용하므로 Manifest/Recipe가 RFQ venue를 허용한 경우에만 +실행된다. + +- full-fill only +- exact taker only +- maker가 서명한 EIP-712 quote만 허용 +- quote domain은 chainId와 RFQAdapter verifying contract에 바인딩 +- quote message는 maker, taker, tokenIn, tokenOut, amountIn, amountOut, venue, + nonce, expiry에 바인딩 +- Adapter 직접 호출은 거부하고 Router를 통한 최신 compliance evaluation 이후에만 + settlement +- settlement는 non-custodial `SafeERC20.transferFrom`으로 taker→maker, + maker→taker를 원자적으로 수행 + +`services/rfq`는 이 quote를 생성·서명하는 최소 reference service다. pricing, +dealer inventory, custody, websocket discovery, orderbook matching, compliance +판단은 포함하지 않는다. + ### Adapter Responsibilities - EIP-712 signature와 domain 검증 - maker/taker/token/amount/price binding - nonce, expiry, replay protection -- dealer/operator 상태 검증 +- Router가 평가한 최신 compliance decision과 venue/operator 상태 반영 - fill 트랜잭션의 최신 decision과 quote parameter binding -- 결정에 따른 partial fill accounting +- v1에서는 partial fill을 허용하지 않고 signed amount와 request amount가 정확히 + 일치해야 함 ### Open Decisions - dealer 승인 모델 -- exact taker 또는 taker class +- exact taker 외 taker class 허용 여부 - partial fill 허용 여부 - settlement custody와 identity 등록 - quote cancellation 방식 -결정 전 기본값은 exact fill, 미등록 dealer 거부, custody 모델 미확정 시 구현 -보류다. +결정 전 기본값은 exact fill, exact taker, non-custodial settlement, Router-only +진입점이다. ## Order Book diff --git a/docs/security.md b/docs/security.md index bae9e4f..94a113e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,6 +42,21 @@ - 실패한 실행은 nonce, fill accounting과 token balance를 원자적으로 되돌려야 한다. - ERC-3643 transfer 실패를 성공으로 취급하거나 swallow하지 않는다. +## RFQ Safety + +- RFQ settlement는 Router-only 진입점이어야 하며 direct adapter call로 compliance + evaluation을 우회할 수 없어야 한다. +- signed quote는 chainId, verifyingContract, maker, taker, tokenIn, tokenOut, + amountIn, amountOut, venue, nonce와 expiry에 바인딩한다. +- quote 생성 backend는 compliance 판단을 하지 않는다. fill 시점의 최신 + `ComplianceEngine.evaluate()`가 최종 gate다. +- JavaScript service에서 온체인 정수는 unsafe `number`를 거부하고 `bigint` 또는 + decimal string을 사용한다. +- 기본 nonce 생성은 같은 millisecond 내 quote 충돌을 만들지 않는 단조 증가 fallback을 + 가져야 한다. +- production dealer approval, signer custody, quote cancellation, partial fill과 + inventory risk는 별도 threat model과 feature spec 전까지 활성화하지 않는다. + ## Logging - 민감한 identity 자료와 법률 문서를 온체인 event나 일반 로그에 기록하지 않는다. @@ -65,7 +80,9 @@ scripts/check.sh ``` -제품 컨트랙트 구현 시 최소한 다음을 추가한다. +현재 구현은 권한, replay/expiry, callback spoof, balance invariant, direct adapter +bypass, Manifest/Recipe evaluation과 RFQ quote mismatch path를 Foundry tests로 +검증한다. production 전 최소한 다음을 계속 유지·확장한다. - 권한 경계 test - replay와 expiry test diff --git a/docs/testing.md b/docs/testing.md index 67fbcce..0a8cfb3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,10 +19,22 @@ forge build forge test --offline ``` -현재는 Counter template test만 존재한다. +현재 제품 테스트는 compliance registry/engine, execution router, AMM adapter, +RFQ adapter와 TREX fixture 기반 integration path를 포함한다. `--offline`은 외부 시그니처 조회를 차단해 로컬 검증을 결정적으로 유지하고, 일부 macOS 환경의 Foundry nightly 프록시 초기화 충돌을 피한다. +RFQ reference service 테스트: + +```sh +cd services/rfq +npm ci +npm test +``` + +이 smoke test는 EIP-712 typed-data shape, expiry/nonce 부여, unsafe JavaScript +number 거부와 monotonic nonce fallback을 검증한다. + Vendored deploy tool 테스트: ```sh @@ -32,9 +44,12 @@ yarn test ### Integration Tests -현재 자동화된 제품 integration test는 없다. `tools/deploy-v3`의 Corner Store -profile은 unit test로 구성과 순서를 검증하며, 과거 수동 Anvil 배포 검증 기록은 -`tools/deploy-v3/CORNER_STORE_PROFILE.md`에 있다. +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`에 있다. + +아직 자동화된 live Anvil deployment/E2E는 없다. ### E2E Tests @@ -58,6 +73,8 @@ scripts/check.sh ``` 이 명령은 현재 저장소에서 지원하는 format, build와 test를 순서대로 실행한다. +현재 포함 범위는 Foundry fmt/build/test, RFQ service smoke, vendored deploy-v3 test, +whitespace check다. ## Manual Verification diff --git a/scripts/check.sh b/scripts/check.sh index 2880a0c..ed78ee6 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -14,6 +14,15 @@ forge build echo "==> Running Foundry tests" forge test --offline +echo "==> Running RFQ service smoke test" +( + cd services/rfq + if [ ! -x node_modules/.bin/tsc ]; then + npm ci + fi + npm test +) + echo "==> Running vendored deploy-v3 tests" ( cd tools/deploy-v3 diff --git a/services/rfq/package-lock.json b/services/rfq/package-lock.json new file mode 100644 index 0000000..3da936d --- /dev/null +++ b/services/rfq/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "@corner-store/rfq-service", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@corner-store/rfq-service", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.7.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + }, + "dependencies": { + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + } + } +} diff --git a/services/rfq/package.json b/services/rfq/package.json new file mode 100644 index 0000000..f30d123 --- /dev/null +++ b/services/rfq/package.json @@ -0,0 +1,13 @@ +{ + "name": "@corner-store/rfq-service", + "version": "0.1.0", + "private": true, + "description": "Reference RFQ quote signer for Corner Store RFQ v1.", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node dist/test/smoke.js" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/services/rfq/src/eip712.ts b/services/rfq/src/eip712.ts new file mode 100644 index 0000000..e8234c3 --- /dev/null +++ b/services/rfq/src/eip712.ts @@ -0,0 +1,36 @@ +import {EIP712Domain, RFQQuote, RFQTypedData} from "./types"; + +export const RFQ_DOMAIN_NAME = "CornerStoreRFQ"; +export const RFQ_DOMAIN_VERSION = "1"; + +export const RFQ_QUOTE_TYPES: RFQTypedData["types"] = { + RFQQuote: [ + {name: "maker", type: "address"}, + {name: "taker", type: "address"}, + {name: "tokenIn", type: "address"}, + {name: "tokenOut", type: "address"}, + {name: "amountIn", type: "uint256"}, + {name: "amountOut", type: "uint256"}, + {name: "venue", type: "address"}, + {name: "nonce", type: "uint256"}, + {name: "expiry", type: "uint64"} + ] +}; + +export function domain(chainId: number, verifyingContract: EIP712Domain["verifyingContract"]): EIP712Domain { + return { + name: RFQ_DOMAIN_NAME, + version: RFQ_DOMAIN_VERSION, + chainId, + verifyingContract + }; +} + +export function typedData(domainValue: EIP712Domain, quote: RFQQuote): RFQTypedData { + return { + domain: domainValue, + types: RFQ_QUOTE_TYPES, + primaryType: "RFQQuote", + message: quote + }; +} diff --git a/services/rfq/src/index.ts b/services/rfq/src/index.ts new file mode 100644 index 0000000..8491ccc --- /dev/null +++ b/services/rfq/src/index.ts @@ -0,0 +1,3 @@ +export * from "./eip712"; +export * from "./quoteService"; +export * from "./types"; diff --git a/services/rfq/src/quoteService.ts b/services/rfq/src/quoteService.ts new file mode 100644 index 0000000..6522f74 --- /dev/null +++ b/services/rfq/src/quoteService.ts @@ -0,0 +1,93 @@ +import {domain, typedData} from "./eip712"; +import {Address, Hex, RFQQuote, RFQQuoteRequest, RFQServiceConfig, SignedRFQQuote, TypedDataSigner} from "./types"; + +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +const HEX_RE = /^0x[a-fA-F0-9]*$/; +const DEFAULT_TTL_SECONDS = 60; + +export class RFQQuoteService { + private readonly config: Required> & + Omit; + + constructor(config: RFQServiceConfig, private readonly signer: TypedDataSigner) { + const nextNonce = config.nextNonce ?? createMonotonicNonceGenerator(); + + this.config = { + defaultTtlSeconds: config.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, + now: config.now ?? (() => Math.floor(Date.now() / 1000)), + nextNonce, + chainId: config.chainId, + verifyingContract: normalizeAddress(config.verifyingContract, "verifyingContract") + }; + } + + async createSignedQuote(request: RFQQuoteRequest): Promise { + const quote = this.createQuote(request); + const data = typedData(domain(this.config.chainId, this.config.verifyingContract), quote); + const signature = assertHex(await this.signer.signTypedData(data), "signature"); + + return {quote, signature, typedData: data}; + } + + createQuote(request: RFQQuoteRequest): RFQQuote { + const ttlSeconds = request.ttlSeconds ?? this.config.defaultTtlSeconds; + if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { + throw new Error("ttlSeconds must be a positive integer"); + } + + return { + maker: normalizeAddress(request.maker, "maker"), + taker: normalizeAddress(request.taker, "taker"), + tokenIn: normalizeAddress(request.tokenIn, "tokenIn"), + tokenOut: normalizeAddress(request.tokenOut, "tokenOut"), + amountIn: toPositiveUintString(request.amountIn, "amountIn"), + amountOut: toPositiveUintString(request.amountOut, "amountOut"), + venue: normalizeAddress(request.venue, "venue"), + nonce: toUintString(request.nonce ?? this.config.nextNonce(), "nonce"), + expiry: this.config.now() + ttlSeconds + }; + } +} + +function normalizeAddress(value: Address, field: string): Address { + if (!ADDRESS_RE.test(value)) throw new Error(`${field} must be a 20-byte hex address`); + return value.toLowerCase() as Address; +} + +function assertHex(value: Hex, field: string): Hex { + if (!HEX_RE.test(value)) throw new Error(`${field} must be hex`); + return value; +} + +function toUintString(value: bigint | string | number, field: string): string { + const parsed = toBigInt(value, field); + if (parsed < 0n) throw new Error(`${field} must be non-negative`); + return parsed.toString(); +} + +function toPositiveUintString(value: bigint | string | number, field: string): string { + const parsed = toBigInt(value, field); + if (parsed <= 0n) throw new Error(`${field} must be positive`); + return parsed.toString(); +} + +function toBigInt(value: bigint | string | number, field: string): bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new Error(`${field} number input must be a safe integer; use bigint or decimal string for large values`); + } + return BigInt(value); + } + + return typeof value === "bigint" ? value : BigInt(value); +} + +function createMonotonicNonceGenerator(): () => bigint { + let lastNonce = 0n; + + return () => { + const candidate = BigInt(Date.now()) * 1000n; + lastNonce = candidate > lastNonce ? candidate : lastNonce + 1n; + return lastNonce; + }; +} diff --git a/services/rfq/src/types.ts b/services/rfq/src/types.ts new file mode 100644 index 0000000..bd0b879 --- /dev/null +++ b/services/rfq/src/types.ts @@ -0,0 +1,60 @@ +export type Hex = `0x${string}`; +export type Address = Hex; + +export interface RFQQuote { + maker: Address; + taker: Address; + tokenIn: Address; + tokenOut: Address; + amountIn: string; + amountOut: string; + venue: Address; + nonce: string; + expiry: number; +} + +export interface SignedRFQQuote { + quote: RFQQuote; + signature: Hex; + typedData: RFQTypedData; +} + +export interface RFQQuoteRequest { + maker: Address; + taker: Address; + tokenIn: Address; + tokenOut: Address; + amountIn: bigint | string | number; + amountOut: bigint | string | number; + venue: Address; + ttlSeconds?: number; + nonce?: bigint | string | number; +} + +export interface EIP712Domain { + name: string; + version: string; + chainId: number; + verifyingContract: Address; +} + +export interface RFQTypedData { + domain: EIP712Domain; + types: { + RFQQuote: Array<{ name: keyof RFQQuote; type: string }>; + }; + primaryType: "RFQQuote"; + message: RFQQuote; +} + +export interface TypedDataSigner { + signTypedData(typedData: RFQTypedData): Promise; +} + +export interface RFQServiceConfig { + chainId: number; + verifyingContract: Address; + defaultTtlSeconds?: number; + now?: () => number; + nextNonce?: () => bigint; +} diff --git a/services/rfq/test/smoke.ts b/services/rfq/test/smoke.ts new file mode 100644 index 0000000..52a7340 --- /dev/null +++ b/services/rfq/test/smoke.ts @@ -0,0 +1,96 @@ +import {RFQ_DOMAIN_NAME, RFQ_DOMAIN_VERSION, RFQQuoteService} from "../src"; +import {RFQQuoteRequest, RFQTypedData, TypedDataSigner} from "../src/types"; + +const MAKER = "0x1000000000000000000000000000000000000001"; +const TAKER = "0x2000000000000000000000000000000000000002"; +const TOKEN_IN = "0x3000000000000000000000000000000000000003"; +const TOKEN_OUT = "0x4000000000000000000000000000000000000004"; +const VENUE = "0x5000000000000000000000000000000000000005"; +const ADAPTER = "0x6000000000000000000000000000000000000006"; + +class CaptureSigner implements TypedDataSigner { + public lastTypedData?: RFQTypedData; + + async signTypedData(typedData: RFQTypedData): Promise<`0x${string}`> { + this.lastTypedData = typedData; + return `0x${"11".repeat(65)}`; + } +} + +async function main() { + const signer = new CaptureSigner(); + const service = new RFQQuoteService( + { + chainId: 31337, + verifyingContract: ADAPTER, + now: () => 1_700_000_000, + nextNonce: () => 42n, + defaultTtlSeconds: 120 + }, + signer + ); + + const signed = await service.createSignedQuote({ + maker: MAKER, + taker: TAKER, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: 100n, + amountOut: 250n, + venue: VENUE + }); + + assert(signed.quote.maker === MAKER.toLowerCase(), "maker normalized"); + assert(signed.quote.taker === TAKER.toLowerCase(), "taker normalized"); + assert(signed.quote.amountIn === "100", "amountIn string"); + assert(signed.quote.amountOut === "250", "amountOut string"); + assert(signed.quote.nonce === "42", "nonce assigned"); + assert(signed.quote.expiry === 1_700_000_120, "expiry assigned"); + assert(signed.signature.length === 132, "65-byte signature"); + assert(signed.typedData.domain.name === RFQ_DOMAIN_NAME, "domain name"); + assert(signed.typedData.domain.version === RFQ_DOMAIN_VERSION, "domain version"); + assert(signed.typedData.domain.chainId === 31337, "chain id"); + assert(signed.typedData.domain.verifyingContract === ADAPTER.toLowerCase(), "verifying contract"); + assert(signer.lastTypedData?.message.venue === VENUE.toLowerCase(), "venue bound into signed message"); + + assertThrows(() => service.createQuote({...baseRequest(), amountIn: Number.MAX_SAFE_INTEGER + 1}), "unsafe amount"); + assertThrows(() => service.createQuote({...baseRequest(), nonce: Number.MAX_SAFE_INTEGER + 1}), "unsafe nonce"); + + const defaultNonceService = new RFQQuoteService({chainId: 31337, verifyingContract: ADAPTER, now: () => 1}, signer); + const firstNonce = BigInt(defaultNonceService.createQuote(baseRequest()).nonce); + const secondNonce = BigInt(defaultNonceService.createQuote(baseRequest()).nonce); + assert(secondNonce > firstNonce, "default nonce is monotonic"); + + console.log("RFQ service smoke ok"); +} + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +function assertThrows(fn: () => unknown, message: string) { + try { + fn(); + } catch { + return; + } + + throw new Error(message); +} + +function baseRequest(): RFQQuoteRequest { + return { + maker: MAKER, + taker: TAKER, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: 100n, + amountOut: 250n, + venue: VENUE + }; +} + +main().catch((err) => { + console.error(err); + throw err; +}); diff --git a/services/rfq/tsconfig.json b/services/rfq/tsconfig.json new file mode 100644 index 0000000..e160751 --- /dev/null +++ b/services/rfq/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "lib": ["ES2020", "DOM"], + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/src/execution/adapters/rfq/RFQAdapter.sol b/src/execution/adapters/rfq/RFQAdapter.sol index 81898a3..3ca6aa6 100644 --- a/src/execution/adapters/rfq/RFQAdapter.sol +++ b/src/execution/adapters/rfq/RFQAdapter.sol @@ -1,18 +1,112 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {Governed} from "../../../auth/Governed.sol"; import {IRFQAdapter} from "../../../interfaces/execution/adapters/IRFQAdapter.sol"; import {ExecutionRequest, ExecutionResult} from "../../../types/ExecutionTypes.sol"; import {ComplianceDecision} from "../../../types/ComplianceTypes.sol"; +import {Errors} from "../../../libraries/Errors.sol"; +import {RFQQuote} from "./RFQTypes.sol"; /// @title RFQAdapter -/// @notice Stub. RFQ execution is not implemented in the skeleton. -contract RFQAdapter is IRFQAdapter { - function execute(ExecutionRequest calldata, ComplianceDecision calldata) +/// @notice Full-fill, exact-taker RFQ settlement adapter. +/// @dev The router owns compliance evaluation and post-trade commit. This adapter only verifies +/// the maker's EIP-712 quote and performs non-custodial settlement. +contract RFQAdapter is IRFQAdapter, Governed, EIP712 { + using SafeERC20 for IERC20; + + bytes32 public constant RFQ_QUOTE_TYPEHASH = keccak256( + "RFQQuote(address maker,address taker,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,address venue,uint256 nonce,uint64 expiry)" + ); + + address public router; + + mapping(address => mapping(uint256 => bool)) public usedQuoteNonce; + + event RouterSet(address indexed router); + event RFQFilled( + bytes32 indexed quoteHash, address indexed maker, address indexed taker, uint256 amountIn, uint256 amountOut + ); + + modifier onlyRouter() { + if (msg.sender != router) revert Errors.NotAuthorized(); + _; + } + + constructor() EIP712("CornerStoreRFQ", "1") {} + + function setRouter(address router_) external onlyOwner { + router = router_; + emit RouterSet(router_); + } + + /// @notice Executes a full-fill RFQ quote for a single router request. + /// @dev `req.venueData` ABI-encodes `(RFQQuote quote, bytes signature)`. + function execute(ExecutionRequest calldata req, ComplianceDecision calldata) external - pure + onlyRouter returns (ExecutionResult memory) { - revert("RFQ: not implemented"); + (RFQQuote memory quote, bytes memory signature) = abi.decode(req.venueData, (RFQQuote, bytes)); + + bytes32 quoteHash = hashQuote(quote); + _validateQuote(req, quote, quoteHash, signature); + + usedQuoteNonce[quote.maker][quote.nonce] = true; + + IERC20(quote.tokenIn).safeTransferFrom(quote.taker, quote.maker, quote.amountIn); + IERC20(quote.tokenOut).safeTransferFrom(quote.maker, quote.taker, quote.amountOut); + + emit RFQFilled(quoteHash, quote.maker, quote.taker, quote.amountIn, quote.amountOut); + + return ExecutionResult({ + amountOut: quote.amountOut, executionId: keccak256(abi.encode(quoteHash, req.nonce, block.number)) + }); + } + + function hashQuote(RFQQuote memory quote) public view returns (bytes32) { + return _hashTypedDataV4( + keccak256( + abi.encode( + RFQ_QUOTE_TYPEHASH, + quote.maker, + quote.taker, + quote.tokenIn, + quote.tokenOut, + quote.amountIn, + quote.amountOut, + quote.venue, + quote.nonce, + quote.expiry + ) + ) + ); + } + + function _validateQuote( + ExecutionRequest calldata req, + RFQQuote memory quote, + bytes32 quoteHash, + bytes memory signature + ) internal view { + if (block.timestamp > quote.expiry) revert Errors.RFQQuoteExpired(); + if (usedQuoteNonce[quote.maker][quote.nonce]) revert Errors.RFQQuoteUsed(); + + if (ECDSA.recover(quoteHash, signature) != quote.maker) revert Errors.RFQInvalidSignature(); + + if ( + quote.maker == address(0) || quote.taker == address(0) || quote.tokenIn == address(0) + || quote.tokenOut == address(0) || quote.venue == address(0) || quote.amountIn == 0 + || quote.amountOut == 0 || quote.taker != req.context.initiator || quote.taker != req.context.buyer + || quote.maker != req.context.seller || quote.tokenIn != req.context.tokenIn + || quote.tokenOut != req.context.tokenOut || quote.amountIn != req.context.amountIn + || quote.amountOut != req.context.amountOut || quote.venue != req.context.venue + ) { + revert Errors.RFQQuoteMismatch(); + } } } diff --git a/src/execution/adapters/rfq/RFQTypes.sol b/src/execution/adapters/rfq/RFQTypes.sol new file mode 100644 index 0000000..f636650 --- /dev/null +++ b/src/execution/adapters/rfq/RFQTypes.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +/// @notice Full-fill, exact-taker RFQ quote signed offchain by the maker. +/// @dev tokenIn/tokenOut are from the taker/buyer perspective: +/// taker pays tokenIn to maker and receives tokenOut from maker. +struct RFQQuote { + address maker; + address taker; + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 amountOut; + address venue; + uint256 nonce; + uint64 expiry; +} diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 9ec59c8..6371c61 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -14,6 +14,10 @@ library Errors { error DecisionMismatch(); // decisionHash != recomputed error MaxAmountExceeded(); error SlippageExceeded(); + error RFQInvalidSignature(); + error RFQQuoteExpired(); + error RFQQuoteUsed(); + error RFQQuoteMismatch(); error ElementNotRegistered(bytes32 elementId); error RecipeNotRegistered(uint16 recipeId); error LooseningForbidden(); // strengthen-only override diff --git a/test/unit/execution/AdapterStubs.t.sol b/test/unit/execution/AdapterStubs.t.sol index 90ac7d0..418aa5d 100644 --- a/test/unit/execution/AdapterStubs.t.sol +++ b/test/unit/execution/AdapterStubs.t.sol @@ -2,17 +2,14 @@ pragma solidity 0.8.17; import {Test} from "forge-std/Test.sol"; -import {RFQAdapter} from "../../../src/execution/adapters/rfq/RFQAdapter.sol"; import {OrderBookAdapter} from "../../../src/execution/adapters/orderbook/OrderBookAdapter.sol"; import {ExecutionRequest} from "../../../src/types/ExecutionTypes.sol"; -import {ComplianceContext, ComplianceDecision, VenueType, FlowType} from "../../../src/types/ComplianceTypes.sol"; +import {ComplianceDecision} from "../../../src/types/ComplianceTypes.sol"; contract AdapterStubsTest is Test { - RFQAdapter internal rfq; OrderBookAdapter internal ob; function setUp() public { - rfq = new RFQAdapter(); ob = new OrderBookAdapter(); } @@ -21,12 +18,6 @@ contract AdapterStubsTest is Test { req.deadline = 0; } - function test_rfq_notImplemented() public { - ComplianceDecision memory d; - vm.expectRevert("RFQ: not implemented"); - rfq.execute(_req(), d); - } - function test_orderbook_notImplemented() public { ComplianceDecision memory d; vm.expectRevert("OrderBook: not implemented"); diff --git a/test/unit/execution/RFQAdapter.t.sol b/test/unit/execution/RFQAdapter.t.sol new file mode 100644 index 0000000..d1070e2 --- /dev/null +++ b/test/unit/execution/RFQAdapter.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {ExecutionRouter} from "../../../src/execution/ExecutionRouter.sol"; +import {VenueRegistry} from "../../../src/execution/VenueRegistry.sol"; +import {VenueSelector} from "../../../src/execution/VenueSelector.sol"; +import {RFQAdapter} from "../../../src/execution/adapters/rfq/RFQAdapter.sol"; +import {OperatorRegistry} from "../../../src/registry/OperatorRegistry.sol"; +import {MockComplianceEngine} from "../../mocks/MockComplianceEngine.sol"; +import {MockERC20} from "../../mocks/MockERC20.sol"; +import {RFQQuote} from "../../../src/execution/adapters/rfq/RFQTypes.sol"; +import {VenueConfig, CustodyModel} from "../../../src/types/VenueTypes.sol"; +import {ExecutionRequest} from "../../../src/types/ExecutionTypes.sol"; +import {ComplianceContext, ComplianceDecision, VenueType, FlowType} from "../../../src/types/ComplianceTypes.sol"; +import {Errors} from "../../../src/libraries/Errors.sol"; + +contract RFQAdapterTest is Test { + uint256 internal constant MAKER_PK = 0xA11CE; + uint256 internal constant WRONG_PK = 0xB0B; + + address internal maker; + address internal taker = address(0xCAFE); + address internal venue = address(0xF00D); + + MockERC20 internal tokenIn; + MockERC20 internal tokenOut; + RFQAdapter internal adapter; + ExecutionRouter internal router; + VenueRegistry internal venueReg; + VenueSelector internal selector; + OperatorRegistry internal operatorReg; + MockComplianceEngine internal engine; + + function setUp() public { + maker = vm.addr(MAKER_PK); + + tokenIn = new MockERC20("TokenIn", "TIN"); + tokenOut = new MockERC20("TokenOut", "TOUT"); + adapter = new RFQAdapter(); + venueReg = new VenueRegistry(); + selector = new VenueSelector(); + operatorReg = new OperatorRegistry(); + engine = new MockComplianceEngine(); + router = new ExecutionRouter(engine, venueReg, selector, operatorReg); + + adapter.setRouter(address(router)); + venueReg.registerVenue( + venue, + VenueConfig({ + venueType: VenueType.RFQ, + adapter: address(adapter), + target: address(0), + operator: address(0), + custody: CustodyModel.NONE, + active: true + }) + ); + + engine.setDecision(_decision(true, 1 << uint256(VenueType.RFQ), bytes32(0), type(uint256).max, bytes32(0))); + + tokenIn.mint(taker, 1_000 ether); + tokenOut.mint(maker, 1_000 ether); + + vm.prank(taker); + tokenIn.approve(address(adapter), type(uint256).max); + vm.prank(maker); + tokenOut.approve(address(adapter), type(uint256).max); + } + + function _decision(bool allowed, uint256 venueTypes, bytes32 venuesHash, uint256 maxAmount, bytes32 reason) + internal + pure + returns (ComplianceDecision memory d) + { + d.allowed = allowed; + d.allowedVenueTypes = venueTypes; + d.allowedVenuesHash = venuesHash; + d.maxAmount = maxAmount; + d.reasonCode = reason; + } + + function _quote(uint256 nonce, uint64 expiry) internal view returns (RFQQuote memory q) { + q.maker = maker; + q.taker = taker; + q.tokenIn = address(tokenIn); + q.tokenOut = address(tokenOut); + q.amountIn = 100 ether; + q.amountOut = 250 ether; + q.venue = venue; + q.nonce = nonce; + q.expiry = expiry; + } + + function _request(RFQQuote memory q, bytes memory signature, uint256 routerNonce) + internal + view + returns (ExecutionRequest memory req) + { + ComplianceContext memory ctx; + ctx.initiator = q.taker; + ctx.buyer = q.taker; + ctx.seller = q.maker; + ctx.tokenIn = q.tokenIn; + ctx.tokenOut = q.tokenOut; + ctx.amountIn = q.amountIn; + ctx.amountOut = q.amountOut; + ctx.venueType = VenueType.RFQ; + ctx.venue = q.venue; + ctx.flowType = FlowType.SECONDARY_TRADE; + + req.context = ctx; + req.amountOutMin = q.amountOut; + req.deadline = uint64(block.timestamp + 1 hours); + req.nonce = routerNonce; + req.venueData = abi.encode(q, signature); + } + + function _sign(RFQQuote memory q, uint256 privateKey) internal view returns (bytes memory) { + bytes32 digest = adapter.hashQuote(q); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + function _validRequest(uint256 quoteNonce, uint256 routerNonce) + internal + view + returns (RFQQuote memory q, ExecutionRequest memory req) + { + q = _quote(quoteNonce, uint64(block.timestamp + 1 hours)); + req = _request(q, _sign(q, MAKER_PK), routerNonce); + } + + function test_execute_validSignedQuoteThroughRouter() public { + (, ExecutionRequest memory req) = _validRequest(1, 1); + + vm.prank(taker); + router.execute(req); + + assertEq(tokenIn.balanceOf(taker), 900 ether, "taker paid tokenIn"); + assertEq(tokenIn.balanceOf(maker), 100 ether, "maker received tokenIn"); + assertEq(tokenOut.balanceOf(taker), 250 ether, "taker received tokenOut"); + assertEq(tokenOut.balanceOf(maker), 750 ether, "maker paid tokenOut"); + assertEq(tokenIn.balanceOf(address(adapter)), 0, "adapter holds no tokenIn"); + assertEq(tokenOut.balanceOf(address(adapter)), 0, "adapter holds no tokenOut"); + assertTrue(engine.committed(), "router committed after RFQ fill"); + } + + function test_revert_directRFQAdapterCallBypass() public { + (, ExecutionRequest memory req) = _validRequest(1, 1); + ComplianceDecision memory d; + + vm.expectRevert(Errors.NotAuthorized.selector); + adapter.execute(req, d); + } + + function test_revert_invalidSignature() public { + RFQQuote memory q = _quote(1, uint64(block.timestamp + 1 hours)); + ExecutionRequest memory req = _request(q, _sign(q, WRONG_PK), 1); + + vm.prank(taker); + vm.expectRevert(Errors.RFQInvalidSignature.selector); + router.execute(req); + } + + function test_revert_expiredQuote() public { + RFQQuote memory q = _quote(1, uint64(block.timestamp)); + ExecutionRequest memory req = _request(q, _sign(q, MAKER_PK), 1); + vm.warp(block.timestamp + 1); + req.deadline = uint64(block.timestamp + 1 hours); + + vm.prank(taker); + vm.expectRevert(Errors.RFQQuoteExpired.selector); + router.execute(req); + } + + function test_revert_reusedQuoteNonce() public { + (, ExecutionRequest memory firstReq) = _validRequest(1, 1); + vm.prank(taker); + router.execute(firstReq); + + (, ExecutionRequest memory replayReq) = _validRequest(1, 2); + vm.prank(taker); + vm.expectRevert(Errors.RFQQuoteUsed.selector); + router.execute(replayReq); + } + + function test_revert_wrongTaker() public { + RFQQuote memory q = _quote(1, uint64(block.timestamp + 1 hours)); + q.taker = address(0xBAD); + ExecutionRequest memory req = _request(q, _sign(q, MAKER_PK), 1); + req.context.initiator = taker; + req.context.buyer = taker; + + vm.prank(taker); + vm.expectRevert(Errors.RFQQuoteMismatch.selector); + router.execute(req); + } + + function test_revert_tokenAmountOrVenueMismatch() public { + RFQQuote memory q = _quote(1, uint64(block.timestamp + 1 hours)); + ExecutionRequest memory req = _request(q, _sign(q, MAKER_PK), 1); + req.context.amountIn = q.amountIn + 1; + + vm.prank(taker); + vm.expectRevert(Errors.RFQQuoteMismatch.selector); + router.execute(req); + } + + function test_revert_complianceRejectedBeforeSettlement() public { + bytes32 reason = bytes32("R-DENY"); + engine.setDecision(_decision(false, 1 << uint256(VenueType.RFQ), bytes32(0), type(uint256).max, reason)); + (, ExecutionRequest memory req) = _validRequest(1, 1); + + vm.prank(taker); + vm.expectRevert(abi.encodeWithSelector(Errors.ComplianceRejected.selector, reason)); + router.execute(req); + + assertEq(tokenIn.balanceOf(taker), 1_000 ether, "no settlement on compliance rejection"); + assertEq(tokenOut.balanceOf(maker), 1_000 ether, "maker funds unchanged"); + } +}