diff --git a/FEATURES.md b/FEATURES.md index 3e1004d..4707df4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -116,6 +116,31 @@ passing - Non-goals: partial fill, orderbook, production pricing engine, dealer inventory, custody 확장, websocket/order discovery. +## DEMO-001 — BUIDL-like ERC-3643 Demo Asset + +### Behavior + +- `src/demo/BuidlLikeDemoAsset.sol`이 Giwa MVP용 BUIDL-like asset profile을 제공한다. +- 테스트 fixture가 profile의 이름/심볼로 실제 ERC-3643/T-REX 토큰을 배포한다. +- profile Manifest는 Reg D 506(c) 발행 Recipe와 ICA 3(c)(7) fund Recipe를 동시에 바인딩한다. +- `factsPacked` fund bit가 켜진 자산에서는 BUIDL-like fund Recipe가 A-13 Qualified Purchaser와 minimum investment 검사를 활성화한다. +- BUIDL-like flow test는 실제 Securitize/TA 연결 대신 `MockSecuritizeTA` fixture로 investor facts를 만들고, 그 결과를 ERC-3643 registry와 Corner Store Elements에 sync한다. +- protected Router 경로에서 QP buyer는 체결되고, accredited-only/non-QP buyer는 토큰 이동 전 거절된다. +- sanctioned QP buyer는 token movement 전에 compliance reject된다. +- QP buyer라도 BUIDL-like minimum investment 미만이면 token movement 전에 compliance reject된다. +- expired TA profile은 current eligibility로 sync되지 않고 token movement 전에 compliance reject된다. +- engine-level AI/QP flags가 통과해도 ERC-3643-unverified recipient는 settlement에서 rollback된다. +- QP/규제 로직은 BUIDL 전용 토큰 override가 아니라 Manifest/Recipe/Element에 남긴다. +- DS Protocol/Securitize-style Compliance Service는 profile 문서에서 adapter seam으로 매핑한다. +- 현실 BlackRock BUIDL 연동, Securitize claim 연동, NAV/redemption/distribution rail은 범위 밖이다. + +### Verification + +- `forge fmt --check src/demo/BuidlLikeDemoAsset.sol test/fixtures/TREXSuite.sol test/fixtures/MockSecuritizeTA.sol test/integration/IntegrationBase.sol test/integration/BUIDLLikeFlow.t.sol src/compliance/elements/BuidlMinimumInvestment.sol src/compliance/recipes/BuidlLikeFundRecipe.sol test/unit/compliance/BuidlLikeFundRecipe.t.sol` +- `forge test --offline --match-path test/integration/BUIDLLikeFlow.t.sol -vv` +- `forge test --offline --match-path test/unit/compliance/BuidlLikeFundRecipe.t.sol -vv` +- `forge test --offline` + ## RFQ-002 — RFQ v2 Hardening ### Behavior @@ -213,6 +238,10 @@ passing ### Notes +- 데모 문서: `docs/compliance/07-buidl-implementation.md` +- Profile spec: `docs/product-specs/buidl-like-demo-profile.md` +- 현재 구현은 local BUIDL-like profile + fixture다. 실제 BUIDL 온보딩은 법률 확정, issuer/trusted-claim 연동, 1차/2차 rail 분리 후 별도 feature로 진행한다. + - 정책 결정: D009(9-element in-place 확장, operator-gated setter, Lockup은 fixture-only mock acquisition source). - Deferred follow-up: ungated legacy mock element(A-01/A-03/QP)의 operator-gate diff --git a/PROGRESS.md b/PROGRESS.md index e5677d4..b73e894 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,6 +19,7 @@ source of truth로 사용한다. - `DOC-001 — Imported Architecture Alignment` - `FND-001 — Foundry Product Foundation` - `RFQ-001 — Reference RFQ Settlement` +- `DEMO-001 — BUIDL-like ERC-3643 Demo Asset` - `RFQ-002 — RFQ v2 Hardening` - `CMP-001 — Reg D 506(c) 9-element Recipe`(illustrative element library + recipe 9-element 확장, version 2) @@ -68,8 +69,7 @@ source of truth로 사용한다. 1. MVP RFQ demo backend milestone/user flow를 별도 문서·feature로 구체화한다. 기존 live-Anvil E2E/CLI 경로를 재사용한다. -2. pending RFQ/E2E/CLI/BUIDL PR stack(#35)이 머지된 뒤 - roadmap과 feature 상태를 재조정한다. +2. pending PR stack 정리 후 roadmap과 feature 상태를 재조정한다. 3. 남은 RFQ production policy를 별도 feature로 분리한다: custody, partial fill, production dealer/operator 책임. 4. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다 @@ -80,39 +80,25 @@ source of truth로 사용한다. ## Last Session Summary -- #30 merge preparation includes `CLI-002` (corner-store CLI v2) on top of current main. src/·script/ 변경 없이 `services/cli`에 - 명령 7개(check/sell/balances/watch/faucet/snapshot/restore/quote-inspect)를 더했다. +- #35 merge preparation includes `DEMO-001 — BUIDL-like ERC-3643 Demo Asset` on top of current main. - 변경한 파일: - - CLI: `services/cli/src/`(abi/config/rfq/commands/index), `services/cli/test/smoke.ts`, - `services/cli/README.md` - - docs/bookkeeping: `docs/demo.md`, `FEATURES.md`(CLI-002), `PROGRESS.md` -- 설계 요점: - - `check`는 active manifest의 recipe → `requiredElements()` → element `check()`를 - eth_call로 돌려 per-element PASS/FAIL을 만들고 `engine.evaluate`(view)로 verdict를 - 낸다. 엔진이 `ctx.buyer`만 스크리닝하므로 subject 무시 원소(B-01/B-02/E-01)를 - `[asset-side]`로 표기. FAIL reason은 recipe-aware code로 디코딩(엔진이 낼 코드와 동일). - - `sell`은 `SwapFlow.t.sol::test_sell_shaped_success`의 컨텍스트(ctx.buyer=매도자, - tokenIn=RWA/tokenOut=QUOTE, venueData=zeroForOne=false)를 그대로 미러링. - - `watch`는 `eth_getLogs`(address+topic0 OR 필터) 폴링. reasonCode는 reason-table → - recipe-0 monitoring-flag(`encode(0,elementId,1)`, SurveillanceFlag) → bytes32 라벨 - 순으로 디코딩; ManifestStatusChanged status·SurveillanceFlag elementId도 사람이 읽게. - - EIP-712 복구는 services/rfq lib의 `domain`+`RFQ_QUOTE_TYPES`로 `verifyTypedData` - (타입 문자열 재선언 없음). ABI는 계속 hand-written fragment(out/ 비의존). -- 실행한 명령: - - `npm run build` / `npm test`(services/cli) - - `scripts/e2e-anvil.sh --keep` + 전체 CLI v2 walkthrough(account 4) - - `forge test --offline` -- 통과한 검증: - - smoke ok: quote-inspect 서명자 복구(valid=maker / tampered≠maker) + reason 회귀. - - live walkthrough: fresh `check`(A-02/A-03/A-04/C-01 FAIL, exit 1) → setup+kyc → - `check`(전 PASS) → faucet → buy(+100) → sell 40(RWA-40/QUOTE+40) → balances 전후 → - snapshot(0x11) → attest ZZ → `check`(A-02 하나 FAIL, exit 1) → restore → `check`(green) - → watch --from 0(세션 이벤트 디코딩 재생) → rfq-quote → quote-inspect(PASS) → 변조 → - quote-inspect(signature FAIL, exit 1). - - `forge test --offline` 238/238 유지(Solidity 변경 없음). + - `src/demo/BuidlLikeDemoAsset.sol` + - `src/compliance/elements/BuidlMinimumInvestment.sol` + - `src/compliance/recipes/BuidlLikeFundRecipe.sol` + - `test/fixtures/MockSecuritizeTA.sol` + - `test/integration/IntegrationBase.sol` + - `test/integration/BUIDLLikeFlow.t.sol` + - `docs/product-specs/buidl-like-demo-profile.md` + - `FEATURES.md`, `PROGRESS.md` +- 완료한 작업: + - BUIDL-like ERC-3643 demo asset profile 추가 + - Mock Securitize/TA fixture로 investor facts를 주입하고 Element flags로 sync + - QP/minimum investment/sanctions/expiry/ERC-3643 recipient verification 시나리오 추가 + - 최신 Manifest lifecycle에 맞춰 BUIDL-like manifest도 register→approve flow로 정합화 +- 실행한 검증: + - original PR CI/checks passed before retarget + - conflict reconciliation checked with `git diff --check` - 남은 리스크: - - manifest는 데모가 recipe 7(Reg D + Surveillance, 10 원소)로 onboarding한 상태라 - `check`가 10개 원소를 매긴다(recipe 1이면 9개). 재배포 시 recipe에 따라 달라짐. - - `snapshot`/`restore`는 anvil 전용. 셸에서 id 캡처 시 `snapshot id:` 라인만 파싱할 것 - (도움말의 "snapshot ids" 문구가 느슨한 grep에 걸릴 수 있음). - - amount는 ether 단위(18 decimals) 가정. 나머지 스택 리스크는 CLI-001/E2E-001과 동일. + - 실제 BlackRock BUIDL/Securitize/TA 연결은 구현 범위가 아니다. + - AI/QP는 아직 production ONCHAINID claim이 아니라 mock TA에서 sync되는 test flag다. + - NAV, redemption rail, monthly distribution, production claim issuer 연동은 별도 feature다. diff --git a/docs/product-specs/buidl-like-demo-profile.md b/docs/product-specs/buidl-like-demo-profile.md new file mode 100644 index 0000000..b94f4c7 --- /dev/null +++ b/docs/product-specs/buidl-like-demo-profile.md @@ -0,0 +1,135 @@ +# BUIDL-like ERC-3643 Demo Profile + +## Status + +Current demo profile; not real BlackRock/Securitize BUIDL integration. + +## Purpose + +This profile lets the Giwa MVP demonstrate how a BUIDL-like regulated fund asset can trade through Corner Store's protected Router path using the existing ERC-3643/T-REX fixture and the Corner Store Manifest/Recipe/Element model. + +The test model is an ERC20-style BUIDL reference asset converted into a local ERC-3643/T-REX test issuance. It is not a bridge, wrapper, or live integration with the real BUIDL token. + +The goal is to prove the product flow, not to claim production compatibility with the real BUIDL token. + +External transfer-agent integration is also modeled, not connected. The test +suite uses a mock Securitize/TA-style fixture as the source of investor facts, +then syncs those facts into the local ERC-3643/T-REX identity registry and +Corner Store Elements. + +## Current implementation + +- Demo profile: `src/demo/BuidlLikeDemoAsset.sol` +- Mock TA fact source: `test/fixtures/MockSecuritizeTA.sol` +- Integration fixture: `test/integration/BUIDLLikeFlow.t.sol` +- ERC-3643/T-REX harness: `test/fixtures/TREXSuite.sol` + +The demo deploys a standard ERC-3643/T-REX token with BUIDL-like metadata: + +- token name: `BUIDL-like ERC-3643 Demo Asset` +- token symbol: `bBUIDL` +- issuance recipe: Reg D 506(c) +- fund recipe: BUIDL-like ICA 3(c)(7) recipe +- fund applicability: `factsPacked` bit 0 +- minimum investment amount: `5,000,000` demo units, modeled as $5M at $1 NAV +- supported execution engine: AMM in the current fixture + +## Compliance model + +The current profile intentionally keeps BUIDL-specific behavior out of token override code. + +```text +BUIDL-like asset facts + -> ManifestCore + -> Recipe binding + -> Element checks + -> Router-protected execution + -> ERC-3643 transfer-time verification +``` + +Current demo checks: + +- KYC/identity verification through the ERC-3643/T-REX fixture +- sanctions clear through `A-01-v1` +- accredited investor through `A-03-v1` +- qualified purchaser through `A-13-v1` +- BUIDL-like minimum investment through `BUIDL-MIN-v1` +- ERC-3643 recipient verification at token transfer time + +Investor facts are seeded through `MockSecuritizeTA`, not by calling the AI/QP +Elements directly from the BUIDL flow test. This keeps the demo shaped like the +production seam without pretending to have a real Securitize or transfer-agent +connection. + +```text +MockSecuritizeTA profile + -> sync into ERC-3643 identity registry when KYC is current + -> sync AI/QP/sanctions flags into test Elements + -> Router evaluate() + -> ERC-3643 transfer-time verification +``` + +## DS Protocol / Securitize mapping + +Public Securitize DS Protocol material describes a Compliance Service style boundary around token operations, including validation / pre-transfer checks. Corner Store should not replace its ERC-3643 identity model with DS-specific internals. The integration boundary should be an adapter. + +| DS / TA concept | Corner Store demo equivalent | +| --- | --- | +| DSToken / permissioned token | ERC-3643/T-REX token fixture | +| Compliance Service | Corner Store `ComplianceEngine` + ERC-3643 transfer-time checks | +| pre-transfer validation | Router `evaluate()` before adapter execution + token `canTransfer` during settlement | +| Wallet / whitelist manager | ERC-3643 `IdentityRegistry` + ONCHAINID claims | +| TA / trusted verification source | `MockSecuritizeTA` in tests; future ERC-3643 `TrustedIssuersRegistry` / TrustedIssuer integration | +| investor eligibility facts | mock TA profile now; claim topics consumed by Elements in production | +| audit / reliance context | decision hash, reason code, events, future reliance log | + +## Claim topics + +The demo follows the PD-4 direction: use ERC-3643-compatible claim topics and a TrustedIssuer-style pipeline rather than inventing a separate identity model. + +Initial project-level topics used by the profile: + +- `KYC = keccak256("CORNER_STORE.KYC")` in the T-REX fixture +- `ACCREDITED_INVESTOR = 1001` +- `QUALIFIED_PURCHASER = 1002` + +The current AI/QP Elements still use settable test flags, but BUIDL-like flow +tests populate those flags through `MockSecuritizeTA`. Production refinement +should replace those flags with ONCHAINID claims carrying issuer, topic, +issuedAt, expiresAt, and revocation/freshness semantics. + +## Demo acceptance cases + +The BUIDL-like profile should demonstrate: + +1. metadata and Manifest binding are correct +2. QP buyer can buy through the protected Router +3. accredited but non-QP buyer is rejected before token movement +4. sanctioned QP buyer is rejected before token movement +5. QP/accredited but ERC-3643-unverified recipient rolls back during token settlement +6. QP/accredited buyer below the BUIDL-like minimum investment is rejected before token movement +7. expired TA profile is not synced into current eligibility and is rejected before token movement + +## Non-goals + +- real BlackRock BUIDL deployment compatibility +- real Securitize/TA API connection +- real Securitize issuer address / topic mapping +- production claim expiry and revocation integration +- NAV oracle integration +- redemption rail implementation +- monthly distribution rail implementation +- production transfer-agent agreement or legal reliance opinion + +## Follow-up work + +- Implement ONCHAINID-backed AI/QP claim fixtures. +- Add claim expiry/freshness tests. +- Add Securitize/DS adapter research issue once official/current integration details are available. +- Split primary distribution, secondary DEX execution, redemption, and monthly distribution rails. +- Promote BUIDL-like profile data into the future Manifest compiler/onboarding flow. + +## References + +- Securitize DS Protocol Compliance Service article: https://medium.com/securitize/ds-protocol-the-compliance-service-b6fe472d625d +- User-provided verified/source browser reference for BUIDL-like DSToken inspection: https://vscode.blockscan.com/ethereum/0x603bb6909be14f83282e03632280d91be7fb83b2 diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md index 5054ff9..c4777f2 100644 --- a/docs/product-specs/index.md +++ b/docs/product-specs/index.md @@ -9,6 +9,7 @@ | [`../architecture/asset-manifest.md`](../architecture/asset-manifest.md) | Asset Compliance Manifest 책임과 lifecycle | Current | | [`../ROADMAP.md`](../ROADMAP.md) | 구현 순서와 완료 조건 | Current | | [`../MVP.md`](../MVP.md) | 과거 AMM 중심 설계 기록 | Historical | +| [`buidl-like-demo-profile.md`](buidl-like-demo-profile.md) | Giwa MVP용 BUIDL-like ERC-3643 demo asset profile | Current | | [`rfq-backend-sdk-and-demo.md`](./rfq-backend-sdk-and-demo.md) | RFQ backend SDK와 MVP demo backend 계획 | Current | DOC-001에서 연구·회의 입력을 반영했다. 내용이 다를 경우 위 current 문서를 source diff --git a/services/cli/src/util.ts b/services/cli/src/util.ts index d72b318..345c053 100644 --- a/services/cli/src/util.ts +++ b/services/cli/src/util.ts @@ -53,4 +53,3 @@ function summarize(name: string, args: any[]): DecodedRevert { // Thrown by commands to signal a clean nonzero exit with a message. export class CliError extends Error {} - diff --git a/src/compliance/elements/BuidlMinimumInvestment.sol b/src/compliance/elements/BuidlMinimumInvestment.sol new file mode 100644 index 0000000..85c9d48 --- /dev/null +++ b/src/compliance/elements/BuidlMinimumInvestment.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseElement} from "./BaseElement.sol"; +import { + ElementMetadata, + ElementCategory, + TemporalNature, + Decidability, + ObligationTiming, + Statefulness +} from "../../types/ComplianceTypes.sol"; +import {ReasonCodes} from "../../libraries/ReasonCodes.sol"; + +/// @dev BUIDL-like demo minimum investment threshold. +/// +/// This is a test-issuance rule for the Giwa MVP profile, not a claim that the +/// live BlackRock/Securitize BUIDL token can be integrated through this element. +/// The engine passes the regulated asset quantity as `amount`; in this demo the +/// BUIDL-like unit is treated as a $1 NAV share, so 5,000,000 tokens models the +/// public $5M minimum-investment fact documented in the product spec. +contract BuidlMinimumInvestment is BaseElement { + bytes32 internal constant ELEMENT_ID = "BUIDL-MIN-v1"; + uint256 public constant MINIMUM_AMOUNT = 5_000_000 ether; + + constructor() + BaseElement(ElementMetadata({ + elementId: ELEMENT_ID, + category: ElementCategory.ASSET_ATTRIBUTE, + version: "BUIDL-MIN-v1", + temporal: TemporalNature.REALTIME, + decidability: Decidability.DETERMINISTIC, + timing: ObligationTiming.AT_TRADE_GATE, + statefulness: Statefulness.STATELESS + })) + {} + + function check(address, address, address, uint256 amount, bytes calldata) + external + pure + override + returns (bool passed, bytes32 reasonCode) + { + passed = amount >= MINIMUM_AMOUNT; + reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1); + } +} diff --git a/src/compliance/recipes/BuidlLikeFundRecipe.sol b/src/compliance/recipes/BuidlLikeFundRecipe.sol new file mode 100644 index 0000000..0e2cb6c --- /dev/null +++ b/src/compliance/recipes/BuidlLikeFundRecipe.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {BaseRecipe} from "./BaseRecipe.sol"; + +/// @dev BUIDL-like fund recipe for the local ERC-3643 test issuance profile. +/// Requires QP plus the demo minimum-investment Element. Conditionally +/// applicable when Manifest.factsPacked bit0 marks the asset as a fund. +contract BuidlLikeFundRecipe is BaseRecipe { + constructor() BaseRecipe(3, 1, _elementsBuidlLikeFund()) {} + + function _elementsBuidlLikeFund() private pure returns (bytes32[] memory e) { + e = new bytes32[](2); + e[0] = "A-13-v1"; + e[1] = "BUIDL-MIN-v1"; + } + + function isApplicable(bytes calldata context) external pure override returns (bool) { + uint256 factsPacked = abi.decode(context, (uint256)); + return (factsPacked & 1) == 1; + } +} diff --git a/src/demo/BuidlLikeDemoAsset.sol b/src/demo/BuidlLikeDemoAsset.sol new file mode 100644 index 0000000..6bc48db --- /dev/null +++ b/src/demo/BuidlLikeDemoAsset.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {ManifestCore, PolicyStatus} from "../types/ComplianceTypes.sol"; + +/// @title BuidlLikeDemoAsset +/// @notice Giwa MVP demo profile for a local BUIDL-like ERC-3643 asset. +/// +/// This intentionally does NOT subclass or fork the ERC-3643 token. The token +/// remains the standard T-REX Token; this profile packages the asset-specific +/// metadata and Manifest binding that make the demo asset BUIDL-like. +/// +/// Keep compliance rules in Manifest/Recipe/Element, not in a one-off token +/// override. That is the product point: asset onboarding is configuration plus +/// policy binding, not bespoke transfer logic per asset. +library BuidlLikeDemoAsset { + string internal constant TOKEN_NAME = "BUIDL-like ERC-3643 Demo Asset"; + 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 FUND_RECIPE_ID = 3; // BUIDL-like ICA 3(c)(7) + minimum investment + uint16 internal constant FUND_RECIPE_VERSION = 1; + + bytes32 internal constant PROFILE_KEY = keccak256("CORNER_STORE.PROFILE.BUIDL_LIKE_DEMO_V1"); + bytes32 internal constant SECURITIZE_DS_ADAPTER_SEAM = keccak256("CORNER_STORE.ADAPTER.SECURITIZE_DS_PROTOCOL"); + uint256 internal constant CLAIM_TOPIC_ACCREDITED_INVESTOR = 1001; + uint256 internal constant CLAIM_TOPIC_QUALIFIED_PURCHASER = 1002; + uint256 internal constant MINIMUM_INVESTMENT_AMOUNT = 5_000_000 ether; + + // Current skeleton convention: factsPacked bit0 means the fund recipe is applicable. + uint256 internal constant FACT_FUND_APPLICABLE = 1; + + 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( + abi.encode( + PROFILE_KEY, + SECURITIZE_DS_ADAPTER_SEAM, + CLAIM_TOPIC_ACCREDITED_INVESTOR, + CLAIM_TOPIC_QUALIFIED_PURCHASER, + MINIMUM_INVESTMENT_AMOUNT + ) + ); + } +} diff --git a/test/fixtures/MockSecuritizeTA.sol b/test/fixtures/MockSecuritizeTA.sol new file mode 100644 index 0000000..e036d60 --- /dev/null +++ b/test/fixtures/MockSecuritizeTA.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +/// @title MockSecuritizeTA +/// @notice Test-only transfer-agent style source of investor eligibility facts. +/// +/// The real Securitize/BUIDL integration is intentionally out of scope for the +/// demo. This fixture models the boundary we need for product testing: an +/// external TA-like system decides whether an investor is KYC verified, +/// accredited, QP, sanctioned, and current; the integration harness then syncs +/// those facts into the local ERC-3643/T-REX + Corner Store test stack. +contract MockSecuritizeTA { + struct InvestorProfile { + bool kycVerified; + bool accreditedInvestor; + bool qualifiedPurchaser; + bool sanctioned; + uint16 country; + uint64 expiresAt; + bytes32 sourceRef; + } + + mapping(address => InvestorProfile) public profileOf; + + event InvestorProfileSet( + address indexed investor, + bool kycVerified, + bool accreditedInvestor, + bool qualifiedPurchaser, + bool sanctioned, + uint16 country, + uint64 expiresAt, + bytes32 sourceRef + ); + + function setInvestorProfile(address investor, InvestorProfile calldata profile) external { + profileOf[investor] = profile; + emit InvestorProfileSet( + investor, + profile.kycVerified, + profile.accreditedInvestor, + profile.qualifiedPurchaser, + profile.sanctioned, + profile.country, + profile.expiresAt, + profile.sourceRef + ); + } + + function isCurrent(address investor) external view returns (bool) { + InvestorProfile memory profile = profileOf[investor]; + return profile.expiresAt == 0 || profile.expiresAt >= block.timestamp; + } +} diff --git a/test/fixtures/TREXCore.sol b/test/fixtures/TREXCore.sol index 2fa81b6..49080ac 100644 --- a/test/fixtures/TREXCore.sol +++ b/test/fixtures/TREXCore.sol @@ -83,10 +83,20 @@ abstract contract TREXCore is CommonBase { deployTREX(address(this)); } + /// @notice Stand up the same stack with scenario-specific token metadata. + function deployTREX(string memory tokenName, string memory tokenSymbol) internal { + deployTREX(address(this), tokenName, tokenSymbol); + } + /// @notice Stand up and wire the full ERC-3643 stack with an explicit admin. /// Under `forge script --broadcast`, pass the broadcasting deployer so /// the registry/token agents and identity management keys are that EOA. function deployTREX(address admin) internal { + deployTREX(admin, "Corner Store RWA", "csRWA"); + } + + /// @notice Stand up the same stack with an explicit admin and token metadata. + function deployTREX(address admin, string memory tokenName, string memory tokenSymbol) internal { trexAdmin = admin; issuerAddr = vm.addr(issuerKey); @@ -120,7 +130,7 @@ abstract contract TREXCore is CommonBase { // 5. token rwaToken = new Token(); - rwaToken.init(address(idRegistry), address(compliance), "Corner Store RWA", "csRWA", 18, address(0)); + rwaToken.init(address(idRegistry), address(compliance), tokenName, tokenSymbol, 18, address(0)); rwaToken.addAgent(admin); // admin may mint rwaToken.unpause(); // token deploys paused } diff --git a/test/fixtures/TREXSuite.sol b/test/fixtures/TREXSuite.sol index eb204ee..324c452 100644 --- a/test/fixtures/TREXSuite.sol +++ b/test/fixtures/TREXSuite.sol @@ -8,12 +8,8 @@ import {TREXCore} from "./TREXCore.sol"; /// @title TREXSuite /// @notice Test-fixture facade over the shared {TREXCore} ERC-3643 (T-REX) + /// OnchainID deployment core. -/// -/// @dev The deployment logic now lives in {TREXCore} (an -/// `abstract is CommonBase`) so it can be reused verbatim by the live deploy -/// script ({DeployStack}). This facade only re-adds `forge-std/Test` (assertions, -/// `expectEmit`, `prank`, …) for the test suite; a test contract inherits it -/// (`contract Foo is TREXSuite`) and calls `deployTREX()` from `setUp()`, exactly -/// as before. `Test` and `TREXCore` share the `CommonBase` ancestor (single `vm`), -/// so the diamond resolves cleanly. +/// @dev The deployment logic lives in {TREXCore} (an `abstract is CommonBase`) so +/// it can be reused verbatim by live deploy scripts. This facade only re-adds +/// `forge-std/Test` for the test suite; a test contract inherits it +/// (`contract Foo is TREXSuite`) and calls `deployTREX()` from `setUp()`. abstract contract TREXSuite is Test, TREXCore {} diff --git a/test/integration/BUIDLLikeFlow.t.sol b/test/integration/BUIDLLikeFlow.t.sol new file mode 100644 index 0000000..8a5148a --- /dev/null +++ b/test/integration/BUIDLLikeFlow.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {IntegrationBase} from "./IntegrationBase.sol"; +import {BuidlLikeDemoAsset} from "../../src/demo/BuidlLikeDemoAsset.sol"; +import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; + +/// @notice Local BUIDL-like ERC-3643 demo asset flow. +/// +/// This is intentionally NOT real BlackRock BUIDL integration. The test deploys +/// the project ERC-3643/T-REX fixture with BUIDL-like metadata, routes investor +/// setup through a mock Securitize/TA-style fact source, and binds a Manifest +/// that models the minimum current demo facts: Reg D 506(c) issuance + ICA +/// 3(c)(7) fund status. In this skeleton, `factsPacked bit0` activates the +/// BuidlLikeFundRecipe, which adds A-13 Qualified Purchaser plus the demo +/// minimum investment gate. +contract BUIDLLikeFlowTest is IntegrationBase { + function setUp() public { + deployBuidlLikeStack(); + } + + function test_buidlLikeAssetMetadataAndManifest() public view { + 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" + ); + assertEq( + policyReg.manifestOf(address(rwaToken)).factsPacked & BuidlLikeDemoAsset.FACT_FUND_APPLICABLE, + BuidlLikeDemoAsset.FACT_FUND_APPLICABLE, + "fund fact enabled" + ); + assertEq( + policyReg.manifestOf(address(rwaToken)).fullManifestHash, + keccak256( + abi.encode( + BuidlLikeDemoAsset.PROFILE_KEY, + BuidlLikeDemoAsset.SECURITIZE_DS_ADAPTER_SEAM, + BuidlLikeDemoAsset.CLAIM_TOPIC_ACCREDITED_INVESTOR, + BuidlLikeDemoAsset.CLAIM_TOPIC_QUALIFIED_PURCHASER, + BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ) + ), + "profile hash anchors demo assumptions" + ); + } + + function test_buidlLike_qpBuyerCanTradeThroughProtectedRouter() public { + setupTaInvestor(alice, true, true, true, false, true); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + ExecutionRequest memory req = buildBuyRequest( + alice, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ); + doBuy(req); + + assertEq( + rwaToken.balanceOf(alice), + BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, + "QP buyer received BUIDL-like asset" + ); + assertEq(quote.balanceOf(alice), 1_000_000 ether, "buyer paid quote"); + } + + function test_buidlLike_accreditedButNonQpBuyerRejectedBeforeTokenMoves() public { + setupTaInvestor(alice, true, true, false, false, true); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + uint256 aliceQuoteBefore = quote.balanceOf(alice); + uint256 poolRwaBefore = rwaToken.balanceOf(address(pool)); + + ExecutionRequest memory req = buildBuyRequest( + alice, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ); + + vm.prank(alice); + vm.expectRevert(); // ComplianceRejected: A-13-v1 from BuidlLikeFundRecipe + router.execute(req); + + assertEq(rwaToken.balanceOf(alice), 0, "non-QP receives no BUIDL-like asset"); + assertEq(quote.balanceOf(alice), aliceQuoteBefore, "quote not pulled on compliance reject"); + assertEq(rwaToken.balanceOf(address(pool)), poolRwaBefore, "pool RWA unchanged"); + } + + function test_buidlLike_sanctionedQpBuyerRejectedBeforeTokenMoves() public { + setupTaInvestor(alice, true, true, true, true, true); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + uint256 aliceQuoteBefore = quote.balanceOf(alice); + uint256 poolRwaBefore = rwaToken.balanceOf(address(pool)); + + ExecutionRequest memory req = buildBuyRequest( + alice, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ); + + vm.prank(alice); + vm.expectRevert(); // ComplianceRejected: A-01-v1 from RegD506cRecipe + router.execute(req); + + assertEq(rwaToken.balanceOf(alice), 0, "sanctioned buyer receives no BUIDL-like asset"); + assertEq(quote.balanceOf(alice), aliceQuoteBefore, "quote not pulled on sanctions reject"); + assertEq(rwaToken.balanceOf(address(pool)), poolRwaBefore, "pool RWA unchanged"); + } + + function test_buidlLike_unverifiedQpRecipientRollsBackSettlement() public { + // TA-derived engine-level AI/QP flags pass, but the real ERC-3643 token + // still rejects transfer to an unregistered recipient identity at + // settlement time. + setupTaInvestor(alice, true, true, true, false, false); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + uint256 aliceQuoteBefore = quote.balanceOf(alice); + uint256 poolRwaBefore = rwaToken.balanceOf(address(pool)); + + ExecutionRequest memory req = buildBuyRequest( + alice, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ); + + vm.prank(alice); + vm.expectRevert(); // ERC-3643 transfer-time verification rejects unverified recipient + router.execute(req); + + assertEq(rwaToken.balanceOf(alice), 0, "unverified recipient receives no BUIDL-like asset"); + assertEq(quote.balanceOf(alice), aliceQuoteBefore, "quote transfer rolled back"); + assertEq(rwaToken.balanceOf(address(pool)), poolRwaBefore, "pool RWA transfer rolled back"); + } + + function test_buidlLike_qpBuyerBelowMinimumRejectedBeforeTokenMoves() public { + setupTaInvestor(alice, true, true, true, false, true); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + uint256 aliceQuoteBefore = quote.balanceOf(alice); + uint256 poolRwaBefore = rwaToken.balanceOf(address(pool)); + + ExecutionRequest memory req = buildBuyRequest(alice, 1_000_000 ether, 1_000_000 ether); + + vm.prank(alice); + vm.expectRevert(); // ComplianceRejected: BUIDL-MIN-v1 from BuidlLikeFundRecipe + router.execute(req); + + assertEq(rwaToken.balanceOf(alice), 0, "below-minimum buyer receives no BUIDL-like asset"); + assertEq(quote.balanceOf(alice), aliceQuoteBefore, "quote not pulled on minimum reject"); + assertEq(rwaToken.balanceOf(address(pool)), poolRwaBefore, "pool RWA unchanged"); + } + + function test_buidlLike_expiredTaProfileRejectedBeforeTokenMoves() public { + setupExpiredTaInvestor(alice, true, true); + fundPoolRWA(6_000_000 ether); + fundBuyerQuote(alice, 6_000_000 ether); + + uint256 aliceQuoteBefore = quote.balanceOf(alice); + uint256 poolRwaBefore = rwaToken.balanceOf(address(pool)); + + ExecutionRequest memory req = buildBuyRequest( + alice, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT, BuidlLikeDemoAsset.MINIMUM_INVESTMENT_AMOUNT + ); + + vm.prank(alice); + vm.expectRevert(); // expired TA profile is not synced into eligibility flags + router.execute(req); + + assertEq(rwaToken.balanceOf(alice), 0, "expired TA profile receives no BUIDL-like asset"); + assertEq(quote.balanceOf(alice), aliceQuoteBefore, "quote not pulled on expired TA profile"); + assertEq(rwaToken.balanceOf(address(pool)), poolRwaBefore, "pool RWA unchanged"); + } +} diff --git a/test/integration/IntegrationBase.sol b/test/integration/IntegrationBase.sol index 1f7ae6d..9e33115 100644 --- a/test/integration/IntegrationBase.sol +++ b/test/integration/IntegrationBase.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.17; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {TREXSuite} from "../fixtures/TREXSuite.sol"; +import {MockSecuritizeTA} from "../fixtures/MockSecuritizeTA.sol"; import {ElementRegistry} from "../../src/registry/ElementRegistry.sol"; import {RecipeRegistry} from "../../src/registry/RecipeRegistry.sol"; @@ -14,6 +15,7 @@ import {ComplianceEngine} from "../../src/compliance/ComplianceEngine.sol"; import {Sanctions} from "../../src/compliance/elements/Sanctions.sol"; import {AccreditedInvestor} from "../../src/compliance/elements/AccreditedInvestor.sol"; import {QualifiedPurchaser} from "../../src/compliance/elements/QualifiedPurchaser.sol"; +import {BuidlMinimumInvestment} from "../../src/compliance/elements/BuidlMinimumInvestment.sol"; import {SurveillanceFlag} from "../../src/compliance/elements/SurveillanceFlag.sol"; import {Jurisdiction} from "../../src/compliance/elements/Jurisdiction.sol"; import {IdentityUniqueness} from "../../src/compliance/elements/IdentityUniqueness.sol"; @@ -25,6 +27,7 @@ import {Lockup} from "../../src/compliance/elements/Lockup.sol"; import {IAcquisitionSource} from "../../src/interfaces/compliance/IAcquisitionSource.sol"; import {RegD506cRecipe} from "../../src/compliance/recipes/RegD506cRecipe.sol"; import {Fund3c7Recipe} from "../../src/compliance/recipes/Fund3c7Recipe.sol"; +import {BuidlLikeFundRecipe} from "../../src/compliance/recipes/BuidlLikeFundRecipe.sol"; import {ExecutionRouter} from "../../src/execution/ExecutionRouter.sol"; import {VenueRegistry} from "../../src/execution/VenueRegistry.sol"; @@ -34,6 +37,7 @@ import {UniswapV3Adapter} from "../../src/execution/adapters/amm/UniswapV3Adapte 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 {ComplianceContext} from "../../src/types/ComplianceTypes.sol"; import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol"; @@ -67,11 +71,13 @@ abstract contract IntegrationBase is TREXSuite { TokenPolicyRegistry internal policyReg; OperatorRegistry internal operatorReg; ComplianceEngine internal engine; + MockSecuritizeTA internal mockTA; // --- elements --------------------------------------------------------- Sanctions internal sanctions; AccreditedInvestor internal accredited; QualifiedPurchaser internal qp; + BuidlMinimumInvestment internal buidlMinimum; SurveillanceFlag internal surveillance; // 9-element Reg D 506(c) reference set (A-01/A-03 above + these): Jurisdiction internal jurisdiction; // A-02-v1 @@ -110,22 +116,42 @@ abstract contract IntegrationBase is TREXSuite { /// @param fundRecipeId 0 (no fund recipe) or 2 (3(c)(7)). /// @param factsPacked manifest facts (bit0 = fund applicable). function deployStack(uint16 fundRecipeId, uint256 factsPacked) internal { - deployTREX(); // real ERC-3643 token() + identity registry + deployStackWithAsset("Corner Store RWA", "csRWA", fundRecipeId, factsPacked); + } + + /// @notice Stand up the same stack with scenario-specific ERC-3643 asset metadata. + function deployStackWithAsset( + string memory tokenName, + string memory tokenSymbol, + uint16 fundRecipeId, + uint256 factsPacked + ) internal { + deployStackWithManifest(tokenName, tokenSymbol, _activeManifest(fundRecipeId, factsPacked)); + } + + /// @notice Stand up the same stack with an explicit asset Manifest/profile. + function deployStackWithManifest(string memory tokenName, string memory tokenSymbol, ManifestCore memory manifest) + internal + { + deployTREX(tokenName, tokenSymbol); // real ERC-3643 token() + identity registry // 1. compliance registries elementReg = new ElementRegistry(); recipeReg = new RecipeRegistry(); policyReg = new TokenPolicyRegistry(); operatorReg = new OperatorRegistry(); + mockTA = new MockSecuritizeTA(); // 2. elements + register sanctions = new Sanctions(); accredited = new AccreditedInvestor(); qp = new QualifiedPurchaser(); + buidlMinimum = new BuidlMinimumInvestment(); surveillance = new SurveillanceFlag(); elementReg.registerElement(bytes32("A-01-v1"), address(sanctions)); elementReg.registerElement(bytes32("A-03-v1"), address(accredited)); elementReg.registerElement(bytes32("A-13-v1"), address(qp)); + elementReg.registerElement(bytes32("BUIDL-MIN-v1"), address(buidlMinimum)); elementReg.registerElement(bytes32("F-02-v1"), address(surveillance)); // 2b. remaining 9-element Reg D 506(c) reference set. AssetClassification @@ -149,6 +175,7 @@ abstract contract IntegrationBase is TREXSuite { // 3. recipes + register recipeReg.registerRecipe(1, 2, address(new RegD506cRecipe())); recipeReg.registerRecipe(2, 1, address(new Fund3c7Recipe())); + recipeReg.registerRecipe(3, 1, address(new BuidlLikeFundRecipe())); // 4. engine engine = new ComplianceEngine(policyReg, elementReg, recipeReg); @@ -171,7 +198,9 @@ abstract contract IntegrationBase is TREXSuite { pool = new MockPool(IERC20(address(quote)), IERC20(address(rwaToken))); // 7. manifests: onboarding goes through the lifecycle (propose -> approve). - policyReg.registerManifest(address(rwaToken), _activeManifest(fundRecipeId, factsPacked)); + // 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.approveManifest(address(rwaToken)); // Quote/cash is out-of-scope: tag UNREGULATED directly from UNKNOWN. policyReg.setUnregulated(address(quote)); @@ -213,6 +242,14 @@ abstract contract IntegrationBase is TREXSuite { deployStack(0, 0); } + /// @dev BUIDL-like demo fixture: Reg D 506(c) + ICA 3(c)(7) fund fact. + /// 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) + ); + } + // --- manifest helper -------------------------------------------------- function _activeManifest(uint16 fundRecipeId, uint256 factsPacked) internal pure returns (ManifestCore memory m) { @@ -255,6 +292,86 @@ abstract contract IntegrationBase is TREXSuite { acqSource.setAcquiredAt(who, address(rwaToken), uint64(1)); } + /// @notice Record a TA-style profile and sync it into the local test stack. + /// @dev `registerIdentity=false` models a TA/claim sync bug where engine + /// flags are present but ERC-3643 registry verification is missing. + function setupTaInvestor( + address who, + bool kycVerified, + bool isAccredited, + bool isQp, + bool isSanctioned, + bool registerIdentity + ) internal { + mockTA.setInvestorProfile( + who, + MockSecuritizeTA.InvestorProfile({ + kycVerified: kycVerified, + accreditedInvestor: isAccredited, + qualifiedPurchaser: isQp, + sanctioned: isSanctioned, + country: DEFAULT_COUNTRY, + expiresAt: uint64(block.timestamp + 365 days), + sourceRef: keccak256(abi.encode("MOCK_SECURITIZE_TA", who, block.chainid)) + }) + ); + + syncTaInvestor(who, registerIdentity); + } + + /// @notice Record an expired TA-style profile. Syncing leaves eligibility + /// flags unset so the protected Router path fails closed. + function setupExpiredTaInvestor(address who, bool isAccredited, bool isQp) internal { + mockTA.setInvestorProfile( + who, + MockSecuritizeTA.InvestorProfile({ + kycVerified: true, + accreditedInvestor: isAccredited, + qualifiedPurchaser: isQp, + sanctioned: false, + country: DEFAULT_COUNTRY, + expiresAt: uint64(block.timestamp + 1), + sourceRef: keccak256(abi.encode("MOCK_SECURITIZE_TA_EXPIRED", who, block.chainid)) + }) + ); + + vm.warp(block.timestamp + 2); + syncTaInvestor(who, true); + } + + function syncTaInvestor(address who, bool registerIdentity) internal { + ( + bool kycVerified, + bool isAccredited, + bool isQp, + bool isSanctioned, + uint16 country, + uint64 expiresAt, + bytes32 sourceRef + ) = mockTA.profileOf(who); + country; + sourceRef; + + bool current = expiresAt == 0 || expiresAt >= block.timestamp; + if (!current) { + accredited.setAccredited(who, false); + qp.setQp(who, false); + sanctions.setBlocked(who, false); + return; + } + + if (kycVerified && registerIdentity) { + verifyInvestor(who); // real OnchainID + KYC claim + } + jurisdiction.setJurisdiction(who, ALLOWED_JURISDICTION); + identity.bindIdentity(who, keccak256(abi.encode("TA_ID", who, sourceRef))); + // C-01 lockup: seed acquisition time at genesis; deployStack warped past it. + acqSource.setAcquiredAt(who, address(rwaToken), uint64(1)); + accredited.setAccredited(who, isAccredited); + qp.setQp(who, isQp); + sanctions.setBlocked(who, isSanctioned); + } + /// @notice Seed the pool with RWA liquidity (so a BUY can deliver RWA out). function fundPoolRWA(uint256 amount) internal { mint(address(pool), amount); // pool is a verified holder diff --git a/test/unit/compliance/BuidlLikeFundRecipe.t.sol b/test/unit/compliance/BuidlLikeFundRecipe.t.sol new file mode 100644 index 0000000..0159645 --- /dev/null +++ b/test/unit/compliance/BuidlLikeFundRecipe.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {BuidlLikeFundRecipe} from "../../../src/compliance/recipes/BuidlLikeFundRecipe.sol"; +import {BuidlMinimumInvestment} from "../../../src/compliance/elements/BuidlMinimumInvestment.sol"; +import {ComplianceContext, VenueType, FlowType} from "../../../src/types/ComplianceTypes.sol"; + +contract BuidlLikeFundRecipeTest is Test { + BuidlLikeFundRecipe internal recipe; + BuidlMinimumInvestment internal minimum; + + function setUp() public { + recipe = new BuidlLikeFundRecipe(); + minimum = new BuidlMinimumInvestment(); + } + + function _ctx() internal pure returns (ComplianceContext memory c) { + c.initiator = address(1); + c.buyer = address(2); + c.seller = address(3); + c.tokenIn = address(4); + c.tokenOut = address(5); + c.amountIn = 10; + c.amountOut = 20; + c.venueType = VenueType.AMM; + c.venue = address(6); + c.flowType = FlowType.SECONDARY_TRADE; + } + + function test_buidlLikeFund_ids_and_elements() public { + assertEq(recipe.recipeId(), uint16(3)); + assertEq(recipe.version(), uint16(1)); + bytes32[] memory e = recipe.requiredElements(); + assertEq(e.length, 2); + assertEq(e[0], bytes32("A-13-v1")); + assertEq(e[1], bytes32("BUIDL-MIN-v1")); + } + + function test_buidlLikeFund_isApplicable_gated_on_fund_bit() public { + ComplianceContext memory c = _ctx(); + assertFalse(recipe.isApplicable(abi.encode(uint256(0), c))); + assertTrue(recipe.isApplicable(abi.encode(uint256(1), c))); + assertTrue(recipe.isApplicable(abi.encode(uint256(0xFF), c))); + assertFalse(recipe.isApplicable(abi.encode(uint256(2), c))); + } + + function test_minimumInvestment_inclusive_boundary() public view { + (bool below,) = minimum.check(address(1), address(2), address(3), minimum.MINIMUM_AMOUNT() - 1, ""); + assertFalse(below); + + (bool exact,) = minimum.check(address(1), address(2), address(3), minimum.MINIMUM_AMOUNT(), ""); + assertTrue(exact); + + (bool above,) = minimum.check(address(1), address(2), address(3), minimum.MINIMUM_AMOUNT() + 1, ""); + assertTrue(above); + } +}