Skip to content
45 changes: 45 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,48 @@ passing
- `check`는 엔진이 direction-aware가 아니라는 점을 도움말·표기로 명시(asset-side 라벨).
- `snapshot`/`restore`는 anvil 전용; `restore`는 이후 스냅샷을 무효화(문서화).
- Non-goals: CLI-001과 동일(프로덕션 key 관리, out/ ABI 커플링, 2차 web3 라이브러리).

## CMP-003 — Wave-2 Illustrative Elements (A-08/A-09/A-11/B-03/B-04/D-01)

### Behavior

- 6개의 신규 illustrative mock element를 element library에 추가한다: A-08
EntityEligibility(entity-level AI/QP 자격 판정, look-through consumer), A-09
EquityOwnerLookThrough(recursive ownership-graph 결과 기록,
`ILookThroughSource` 구현), A-11 ClaimFreshness(AI/QP claim의
verifiedAt/issuerExpiry freshness gate), B-03 TransferRestrictionMetadata
(asset-side 양도제한 legend 선언의 완전성/일관성 검사), B-04
EngineSelection(trade-context 기반 허용 execution engine 게이트;
`ComplianceContext`를 디코딩하는 유일한 element), D-01 HolderCount(STATEFUL;
§12(g)/§3(c)(1)/506(b) holder-count cap).
- 각 element는 기존 illustrative library와 동일한 패턴을 따른다:
`BaseElement`(D-01은 `BaseStatefulElement`) + operator-gated attestation
setter + 전용 Foundry unit test.
- `script/DeployStack.s.sol`이 6개 element 전부를 `ElementRegistry`에 등록한다:
A-09를 먼저 배포해 그 주소를 A-08 생성자에 `ILookThroughSource`로 주입하고,
D-01은 A-04(IdentityUniqueness)/A-03(AccreditedInvestor) 주소와
`CapMode.TWELVE_G`로 생성한 뒤 engine 생성 이후 `setEngine`으로 post-trade
write path를 연결한다(F-02 SurveillanceFlag와 동일 패턴).
- 이 wave는 element library 확장에 한정된다: 어떤 recipe의
`requiredElements`에도 연결하지 않는다(recipe 부착은 별도 feature).

### Verification

- 개별 unit test: `test/unit/compliance/elements/{EntityEligibility,
EquityOwnerLookThrough,ClaimFreshness,TransferRestrictionMetadata,
EngineSelection,HolderCount}.t.sol`.
- `forge build --offline`.
- `forge test --offline`(전체 399/399 유지; `DeployStack.s.sol`은 forge test
경로에서 실행되지 않으므로 등록 변경으로 인한 기존 test 회귀 없음).
- `forge fmt script/DeployStack.s.sol`.

### State

passing

### Notes

- Non-goals: 이번 wave의 recipe/manifest 연결(어떤 recipe도 새 element를
요구하지 않음), production legal 활성화, look-through graph 실제 순회.
- Deferred follow-up: 6개 element를 실제 recipe(`requiredElements`)에 연결할지,
연결한다면 어떤 recipe에 붙일지는 별도 feature에서 결정한다.
47 changes: 30 additions & 17 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ source of truth로 사용한다.
`snapshot`/`restore`, `quote-inspect`(서명자 복구/만료/nonce·승인, 실패 시 exit 1).
reason 디코딩·EIP-712 복구는 기존 lib 재사용. smoke(quote-inspect valid+tampered) +
full live walkthrough로 검증. `forge test --offline` 238/238 유지.
- `CMP-003 — Wave-2 Illustrative Elements`(A-08 EntityEligibility, A-09
EquityOwnerLookThrough, A-11 ClaimFreshness, B-03 TransferRestrictionMetadata,
B-04 EngineSelection, D-01 HolderCount 6개 mock element + 전용 unit test):
`script/DeployStack.s.sol`에 전부 등록(A-09 → A-08 생성자 주입, D-01은
A-04/A-03 주소 + `CapMode.TWELVE_G`로 생성 후 engine 생성 이후 `setEngine`).
어떤 recipe의 `requiredElements`에도 연결하지 않음(별도 feature로 이연).
`forge test --offline` 399/399 유지.

## Blocked

Expand All @@ -77,28 +84,34 @@ source of truth로 사용한다.
5. 실제 Uniswap v3 pool 배포를 demo/E2E에 연결한다(현재 AMM venue는 MockPool;
`tools/deploy-v3` vendor isolation 유지).
6. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
7. CMP-003의 6개 wave-2 element(A-08/A-09/A-11/B-03/B-04/D-01)를 실제 recipe
`requiredElements`에 연결할지, 연결한다면 어떤 recipe에 붙일지 결정한다.

## Last Session Summary

- #35 merge preparation includes `DEMO-001 — BUIDL-like ERC-3643 Demo Asset` on top of current main.
- `CMP-003 — Wave-2 Illustrative Elements`: A-08/A-09/A-11/B-03/B-04/D-01
6개 mock element(사전 세션에서 개별 구현 + unit test 완료)를
`script/DeployStack.s.sol`에 등록하는 integration 작업.
- 변경한 파일:
- `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`
- `script/DeployStack.s.sol`
- `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로 정합화
- A-09(EquityOwnerLookThrough)를 먼저 배포하고 그 주소를 A-08
(EntityEligibility) 생성자에 `ILookThroughSource`로 주입
- A-11(ClaimFreshness), B-03(TransferRestrictionMetadata),
B-04(EngineSelection)를 문서화된 생성자 인자로 배포·등록
- D-01(HolderCount)을 위해 A-04(IdentityUniqueness)/A-03(AccreditedInvestor)
생성 결과를 state 변수로 캡처하도록 최소 리팩터링하고, `CapMode.TWELVE_G` +
두 주소로 생성 후 engine 생성 이후 `setEngine`으로 post-trade write path
연결(F-02 SurveillanceFlag와 동일 패턴)
- recipe `requiredElements`는 의도적으로 변경하지 않음(별도 feature로 이연)
- 실행한 검증:
- original PR CI/checks passed before retarget
- conflict reconciliation checked with `git diff --check`
- `forge build --offline`
- `forge test --offline`(전체 399/399 유지; 기존 test 조정 불필요 —
`DeployStack.s.sol`은 forge test 경로에서 실행되지 않음)
- `forge fmt script/DeployStack.s.sol`
- 남은 리스크:
- 실제 BlackRock BUIDL/Securitize/TA 연결은 구현 범위가 아니다.
- AI/QP는 아직 production ONCHAINID claim이 아니라 mock TA에서 sync되는 test flag다.
- NAV, redemption rail, monthly distribution, production claim issuer 연동은 별도 feature다.
- 6개 element는 아직 어떤 recipe에도 연결되지 않아 trade-path 동작이
검증되지 않았다(단위 테스트만 존재).
- `DeployStack.s.sol`의 실제 라이브 Anvil 배포/CLI 경로 재실행(`scripts/e2e-anvil.sh`)은
이번 세션에서 수행하지 않았다.
41 changes: 39 additions & 2 deletions script/DeployStack.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {AssetClassification} from "../src/compliance/elements/AssetClassificatio
import {Erc3643Native} from "../src/compliance/elements/Erc3643Native.sol";
import {FormDFiling} from "../src/compliance/elements/FormDFiling.sol";
import {Lockup} from "../src/compliance/elements/Lockup.sol";
import {EntityEligibility} from "../src/compliance/elements/EntityEligibility.sol";
import {EquityOwnerLookThrough} from "../src/compliance/elements/EquityOwnerLookThrough.sol";
import {ClaimFreshness} from "../src/compliance/elements/ClaimFreshness.sol";
import {TransferRestrictionMetadata} from "../src/compliance/elements/TransferRestrictionMetadata.sol";
import {EngineSelection} from "../src/compliance/elements/EngineSelection.sol";
import {HolderCount, IIdentityView, IAiView} from "../src/compliance/elements/HolderCount.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";
Expand Down Expand Up @@ -82,6 +88,13 @@ contract DeployStack is Script, TREXCore, DemoConstants {
Lockup internal lockup;
DemoAcquisitionSource internal acqSource;

// wave-2 elements (illustrative library extension; kept in variables only
// where a later constructor/wiring step needs the address).
IdentityUniqueness internal identityUniqueness;
AccreditedInvestor internal accreditedInvestor;
EquityOwnerLookThrough internal equityOwnerLookThrough;
HolderCount internal holderCountElement;

ExecutionRouter internal router;
VenueRegistry internal venueReg;
VenueSelector internal selector;
Expand Down Expand Up @@ -136,6 +149,7 @@ contract DeployStack is Script, TREXCore, DemoConstants {
ammAdapter.setRouter(address(router));
rfqAdapter.setRouter(address(router));
surveillance.setEngine(address(engine));
holderCountElement.setEngine(address(engine));

// 6. tokens + AMM pool (token0=QUOTE, token1=RWA).
quote = new MockERC20("Quote USD", "qUSD");
Expand Down Expand Up @@ -204,8 +218,10 @@ contract DeployStack is Script, TREXCore, DemoConstants {
elementReg.registerElement(bytes32("A-01-v1"), address(new Sanctions()));
jurisdiction = new Jurisdiction();
elementReg.registerElement(bytes32("A-02-v1"), address(jurisdiction));
elementReg.registerElement(bytes32("A-03-v1"), address(new AccreditedInvestor()));
elementReg.registerElement(bytes32("A-04-v1"), address(new IdentityUniqueness()));
accreditedInvestor = new AccreditedInvestor();
elementReg.registerElement(bytes32("A-03-v1"), address(accreditedInvestor));
identityUniqueness = new IdentityUniqueness();
elementReg.registerElement(bytes32("A-04-v1"), address(identityUniqueness));
elementReg.registerElement(bytes32("A-05-v1"), address(new UsTaxResident()));
assetClass = new AssetClassification(REG_D_CLASS);
elementReg.registerElement(bytes32("B-01-v1"), address(assetClass));
Expand All @@ -219,6 +235,27 @@ contract DeployStack is Script, TREXCore, DemoConstants {
surveillance = new SurveillanceFlag();
elementReg.registerElement(bytes32("A-13-v1"), address(new QualifiedPurchaser()));
elementReg.registerElement(bytes32("F-02-v1"), address(surveillance));

// --- wave 2: A-08/A-09/A-11/B-03/B-04/D-01 (illustrative library
// extension; NOT wired into any recipe's requiredElements — see
// FEATURES.md). A-09 must exist before A-08 so its address can be
// injected as A-08's ILookThroughSource seam.
equityOwnerLookThrough = new EquityOwnerLookThrough();
elementReg.registerElement(bytes32("A-09-v1"), address(equityOwnerLookThrough));
elementReg.registerElement(bytes32("A-08-v1"), address(new EntityEligibility(equityOwnerLookThrough)));
elementReg.registerElement(bytes32("A-11-v1"), address(new ClaimFreshness()));
elementReg.registerElement(bytes32("B-03-v1"), address(new TransferRestrictionMetadata()));
elementReg.registerElement(bytes32("B-04-v1"), address(new EngineSelection()));
// D-01 is STATEFUL and needs the A-04/A-03 addresses above plus a cap
// regime; TWELVE_G is the demo-sensible default (the other two modes
// are library completeness, doc HolderCount.sol). Engine wiring
// happens later, once `engine` exists (mirrors surveillance.setEngine).
holderCountElement = new HolderCount(
HolderCount.CapMode.TWELVE_G,
IIdentityView(address(identityUniqueness)),
IAiView(address(accreditedInvestor))
);
elementReg.registerElement(bytes32("D-01-v1"), address(holderCountElement));
}

/// @dev Full investor-side engine attestations for the 9-element recipe.
Expand Down
134 changes: 134 additions & 0 deletions src/compliance/elements/ClaimFreshness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;

import {BaseElement} from "./BaseElement.sol";
import {Governed} from "../../auth/Governed.sol";
import {
ElementMetadata,
ElementCategory,
TemporalNature,
Decidability,
ObligationTiming,
Statefulness
} from "../../types/ComplianceTypes.sol";
import {ReasonCodes} from "../../libraries/ReasonCodes.sol";

/// @dev A-11-v1 Claim freshness (mock). Stands in for the on-chain freshness
/// gate on an already-issued AI/QP claim: A-03/A-13 judge claim
/// *substance* (is this person actually AI/QP); A-11 only judges whether
/// the claim's `verifiedAt` timestamp is still within its reuse window at
/// trade time. Pure deterministic timestamp arithmetic — no look-through,
/// no manual review path (doc §5.5, §6.3): the only cure for any failure
/// here is re-verification producing a fresh `verifiedAt`.
///
/// Reason code numbers (element-doc §6.2 names), in `check()` evaluation
/// order:
/// 1 = FAIL_NO_VERIFIED_AT claim.verifiedAt unset
/// 2 = FAIL_CLAIM_STALE_AI AI claim older than CAP_AI (5y, statutory)
/// 3 = FAIL_CLAIM_STALE_QP QP claim older than CAP_QP (1y, POLICY)
/// 4 = FAIL_CLAIM_EXPIRED issuer-set expiry (shorter than the cap) passed
/// 5 = FAIL_UNKNOWN_CLAIM_TYPE claimType not AI/QP — fail-closed, never
/// defaults to the laxer AI cap
contract ClaimFreshness is BaseElement, Governed {
bytes32 internal constant ELEMENT_ID = "A-11-v1";

enum FreshClaimType {
UNKNOWN,
AI,
QP
}

struct FreshnessClaim {
FreshClaimType claimType;
uint64 verifiedAt; // 0 = unset (no freshness anchor to compute from)
uint64 issuerExpiry; // 0 = none; if set and shorter than the regulatory/
// policy cap, the issuer-set expiry wins (tighter always dominates)
}

/// @dev Rule 506(c)(2)(ii)(E): a prior AI verification may be reused "for a
/// period of five years from the date the person was previously
/// verified" — this cap is STATUTORY, not a Decipher choice.
uint64 public constant CAP_AI = 5 * 365 days;

/// @dev Decipher POLICY value — NOT statute. ICA §3(c)(7) requires QP
/// status only "at the time of acquisition" and contains no
/// re-verification or expiry provision at all; this 1-year window is
/// a conservative Decipher risk buffer, deliberately narrower than
/// the statutory 5-year AI window, because a single non-qualifying
/// holder collapses the entire fund's §3(c)(7) exemption. This value
/// must never be represented as a legal requirement in comments,
/// docs, or user-facing messages — it is a risk-management choice.
uint64 public constant CAP_QP = 365 days;

/// @notice user => attested freshness claim.
mapping(address => FreshnessClaim) public claimOf;

event ClaimSet(address indexed user, FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry);

constructor()
BaseElement(ElementMetadata({
elementId: ELEMENT_ID,
category: ElementCategory.INVESTOR_ATTRIBUTE,
version: "A-11-v1",
temporal: TemporalNature.PERIODIC,
decidability: Decidability.DETERMINISTIC,
timing: ObligationTiming.AT_TRADE_GATE,
statefulness: Statefulness.STATELESS
}))
{}

/// @notice Records (or overwrites) the attested freshness anchor for `user`.
/// @dev `verifiedAt`/`issuerExpiry` are attested off-chain by the Trusted
/// Issuer that signed the underlying AI/QP claim; A-11 trusts them at
/// face value and performs only deterministic timestamp arithmetic on
/// top of that trust (doc §5.5, §8.3, §10.4 — liability for a wrong
/// `verifiedAt` sits with the issuer/attester, not with this element).
function setClaim(address user, FreshClaimType claimType, uint64 verifiedAt, uint64 issuerExpiry)
external
onlyOperator
{
claimOf[user] = FreshnessClaim({claimType: claimType, verifiedAt: verifiedAt, issuerExpiry: issuerExpiry});
emit ClaimSet(user, claimType, verifiedAt, issuerExpiry);
}

/// @dev doc §5.2 order, T_tx = block.timestamp (the settlement block, per
/// §5.4's recommendation):
/// 1. `verifiedAt == 0` -> FAIL_NO_VERIFIED_AT (no arithmetic anchor).
/// 2. `claimType == UNKNOWN` -> FAIL_UNKNOWN_CLAIM_TYPE, fail-closed
/// (never silently treated as the laxer AI cap).
/// 3. Pick the cap by type, then take the earlier of the
/// regulatory/policy expiry and any issuer-set expiry.
/// 4. Strict `>` against T_tx: exactly-at-cap PASSes (doc §5.3 —
/// the cap is an inclusive window, only exceeding it fails).
function check(address user, address, address, uint256, bytes calldata)
external
view
override
returns (bool passed, bytes32 reasonCode)
{
FreshnessClaim memory c = claimOf[user];

if (c.verifiedAt == 0) {
return (false, ReasonCodes.encode(0, ELEMENT_ID, 1));
}

if (c.claimType == FreshClaimType.UNKNOWN) {
return (false, ReasonCodes.encode(0, ELEMENT_ID, 5));
}

uint64 cap = c.claimType == FreshClaimType.QP ? CAP_QP : CAP_AI;
uint64 regulatoryExpiry = c.verifiedAt + cap;
bool issuerBinds = c.issuerExpiry != 0 && c.issuerExpiry < regulatoryExpiry;
uint64 effectiveExpiry = issuerBinds ? c.issuerExpiry : regulatoryExpiry;

if (block.timestamp > effectiveExpiry) {
if (issuerBinds) {
return (false, ReasonCodes.encode(0, ELEMENT_ID, 4));
}
uint32 staleCode = c.claimType == FreshClaimType.QP ? 3 : 2;
return (false, ReasonCodes.encode(0, ELEMENT_ID, staleCode));
}

return (true, bytes32(0));
}
}
Loading
Loading