Skip to content
Open
70 changes: 70 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,73 @@ RFQ venue의 다음 정책을 확정한다.
- `docs/security.md`
- `src/execution/adapters/rfq/RFQAdapter.sol`
- `FEATURES.md`

## D008 — Reg D 506(c) recipe를 9-element reference set으로 확장한다

Date: 2026-07-04

### Context

reference Reg D 506(c) set을 향한 compliance module buildout이 최우선 작업이다.
strategy report note-14는 Reg D 506(c) 판정을 9개 element로 분해한다. 기존
`RegD506cRecipe`는 illustrative 2-element(A-01 sanctions + A-03 accredited)만
요구했다. 나머지 element와 recipe 확장을 landing하면서, 열린 legal 결정과
production data source는 여전히 approval-gated로 남겨야 한다(ROADMAP Phase 1:
"implemented for illustrative/reference Elements and Recipes; production legal
criteria remain approval-gated").

### Decision

- 6개의 새 illustrative element를 추가한다(note-14 Reg D 506(c) set의 adapter):
`Jurisdiction`(A-02), `IdentityUniqueness`(A-04), `UsTaxResident`(A-05),
`AssetClassification`(B-01, `bytes32 requiredClassification` 생성자 인자),
`Erc3643Native`(B-02), `FormDFiling`(E-01). attestation setter는 모두
operator-gated(`Governed`/`onlyOperator`)이며, production data source
(OFAC oracle, ONCHAINID claim, ERC-165 introspection, EDGAR)는 approval-gated
seam으로 남는다.
- `RegD506cRecipe`를 in-place로 9-element set으로 확장한다(순서: A-01, A-02,
A-03, A-04, A-05, B-01, B-02, C-01, E-01). recipe id는 1 유지, version은 2로
bump, 여전히 always-applicable. 이는 illustrative reference wiring이며 approved
production policy가 아니다(ROADMAP/MVP-v2 gating 언어를 따른다).
- C-01 Lockup은 test fixture에서 injected mock acquisition source
(`IAcquisitionSource`)를 통해서만 참여한다. production acquisition/lot data
source 결정과 holding-period 활성화 default는 변경하지 않는다(CR-3 seam,
ROADMAP: "acquisition data가 필요한 Recipe는 data source가 결정되기 전
활성화하지 않는다"). recipe는 illustrative fixture로 남는다.

### Alternatives Considered

- 새 element에 ungated setter를 사용(legacy mock element와 동일): state-input
write-gate가 없어 hardening 방향과 어긋나므로 제외. 대신 새 element는
`Governed`/`onlyOperator`를 쓴다(legacy element와의 divergence).
- Lockup을 recipe에서 제외: 9-element reference set을 완전히 wiring하지 못하므로
제외. mock acquisition source로 fixture에서만 활성화한다.
- production data source(OFAC/ONCHAINID/ERC-165/EDGAR)를 지금 연결: legal 검토와
data source 결정이 미완이라 제외. approval-gated seam으로 남긴다.

### Consequences

- ungated legacy mock element(Sanctions A-01, AccreditedInvestor A-03,
QualifiedPurchaser)와 새 operator-gated element 사이에 hardening divergence가
생긴다. legacy element 정렬은 follow-up으로 추적한다.
- `RegD506cRecipe`(id 1)를 issuance recipe로 쓰는 모든 fixture(unit
`Engine.t.sol`, 통합 `IntegrationBase`/`SwapFlow`)는 9개 element를 모두
만족하도록 buyer/asset attestation을 추가해야 했다(fix fixtures, not product
code). engine/registry/router product code는 변경하지 않았다.
- production 활성화 전 legal 검토, acquisition/lot data source 결정, production
data source 연결은 여전히 open이다.

### Related Files

- `src/compliance/recipes/RegD506cRecipe.sol`
- `src/compliance/elements/Jurisdiction.sol`,
`src/compliance/elements/IdentityUniqueness.sol`,
`src/compliance/elements/UsTaxResident.sol`,
`src/compliance/elements/AssetClassification.sol`,
`src/compliance/elements/Erc3643Native.sol`,
`src/compliance/elements/FormDFiling.sol`,
`src/compliance/elements/Lockup.sol`
- `test/integration/IntegrationBase.sol`,
`test/integration/RegD506cElements.t.sol`
- `test/unit/compliance/Recipes.t.sol`, `test/unit/compliance/Engine.t.sol`
- `docs/ROADMAP.md`
46 changes: 46 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,49 @@ passing
registry.
- Deferred follow-up: router-path maker-approval/cancellation integration-test
시나리오.

## CMP-001 — Reg D 506(c) 9-element Recipe

### Behavior

- illustrative Reg D 506(c) element library가 reference 9-element set을 커버한다:
A-01 sanctions, A-02 jurisdiction, A-03 accredited, A-04 identity uniqueness,
A-05 US-tax-resident 제외, B-01 asset classification(REG_D), B-02
ERC-3643-native asset, C-01 Rule 144 lockup, E-01 Form D filing.
- `RegD506cRecipe`는 이 9-element set을 요구한다(id 1, version 2,
always-applicable). illustrative reference wiring이며 approved production
policy가 아니다.
- 새 element의 attestation setter는 operator-gated(`Governed`/`onlyOperator`)이며
production data source(OFAC/ONCHAINID/ERC-165/EDGAR)는 approval-gated seam으로
남는다.
- C-01 Lockup은 injected mock acquisition source를 통해 test fixture에서만
활성화된다. production holding-period 활성화 default는 변경하지 않는다.
- 완전히 attested된 buyer + asset은 real ERC-3643 router 경로로 settle되고,
element family별로 하나를 깨면 그 element의 reasonCode로 거부된다.

### Verification

- `forge test --offline`(전체 195/195, pre-task 189 + 신규 6).
- Recipe unit test: `test_regd_ids_and_elements`(9 element, version 2, id 1,
always-applicable).
- 통합 test `test/integration/RegD506cElements.t.sol`:
`test_happyPath_nineElements_buySucceeds`,
`test_reject_jurisdictionDisallowed`, `test_reject_jurisdictionUnset`,
`test_reject_identityUnbound`, `test_reject_usTaxResidentFlagged`,
`test_reject_assetNotClassifiedRegD`.
- 기존 통합/unit fixture(MultiRecipe, Surveillance, EmergencyPause, Invariants,
SwapFlow, Engine)는 shared setup helper로 9-element attestation을 추가해 유지.
- `forge fmt`.

### State

passing

### Notes

- 정책 결정: D008(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
정렬, production data source 연결, acquisition/lot data source와 holding-period
활성화 조건 결정.
- Non-goals: production legal 활성화, direction-aware element application.
61 changes: 33 additions & 28 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ source of truth로 사용한다.
- `FND-001 — Foundry Product Foundation`
- `RFQ-001 — Reference RFQ Settlement`
- `RFQ-002 — RFQ v2 Hardening`
- `CMP-001 — Reg D 506(c) 9-element Recipe`(illustrative element library + recipe
9-element 확장, version 2)
- multi-venue 아키텍처와 책임 문서 작성
- Corner Store용 Uniswap v3 최소 배포 profile 분리와 테스트
- ExecutionRouter/VenueRegistry/VenueSelector와 AMM reference adapter skeleton
Expand All @@ -36,39 +38,42 @@ source of truth로 사용한다.

## Next

1. Compliance module buildout을 최우선으로 진행한다(사용자/steering 결정):
reference Reg D 506(c) set을 향한 Element library → Recipe 확장 → Manifest
lifecycle/operator approval flow.
2. RFQ integration-test 시나리오(router-path maker-approval/cancellation coverage)를
추가한다 — 이번 feature에서 deferred된 follow-up.
3. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다.
4. live Anvil deployment/E2E를 추가한다.
5. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
6. CI hardening(static analysis 등)을 강화한다.
1. Manifest lifecycle/operator approval flow를 구현한다. (Element library와 Reg D
506(c) Recipe 9-element 확장은 CMP-001로 완료.)
2. ungated legacy mock element(A-01 sanctions, A-03 accredited, QP)를 새 element와
동일하게 operator-gate로 정렬한다 — CMP-001 deferred follow-up.
3. RFQ integration-test 시나리오(router-path maker-approval/cancellation coverage)를
추가한다 — RFQ-002에서 deferred된 follow-up.
4. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다
(C-01 Lockup은 현재 fixture-only mock acquisition source).
5. live Anvil deployment/E2E를 추가한다.
6. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
7. CI hardening(static analysis 등)을 강화한다.

## Last Session Summary

- 변경한 파일(docs/bookkeeping only, 이번 세션):
- `docs/rfq-threat-model.md`(신규 위협 모델)
- `docs/security.md`(RFQ Safety에 위협 모델 링크)
- `docs/README.md`(doc index 항목)
- `DECISIONS.md`(D007), `FEATURES.md`(RFQ-002), `PROGRESS.md`
- 앞선 코드 세션(RFQ-002)에서 landed:
- RFQAdapter maker approval gate(`setMakerApproved`, `approvedMaker`,
`RFQMakerNotApproved`), maker-initiated cancellation(`cancelQuoteNonce`,
`cancelQuoteNonces`, `RFQQuoteCancelled`), router venueType binding fix
- RFQAdapter/Router Foundry tests(9 RFQ + 1 router venueType)
- CMP-001 (Reg D 506(c) 9-element Recipe)을 landing했다.
- 변경한 파일:
- product: `src/compliance/recipes/RegD506cRecipe.sol`(2→9 element, version 2)
- test: `test/unit/compliance/Recipes.t.sol`, `test/unit/compliance/Engine.t.sol`,
`test/integration/IntegrationBase.sol`, `test/integration/SwapFlow.t.sol`,
신규 `test/integration/RegD506cElements.t.sol`
- bookkeeping: `DECISIONS.md`(D008), `FEATURES.md`(CMP-001), `PROGRESS.md`
- 6개 새 illustrative element(A-02/A-04/A-05/B-01/B-02/E-01)는 앞선 리뷰된
브랜치에서 landed; 이번 세션은 recipe 확장 + fixture wiring + bookkeeping.
- 실행한 명령:
- `forge fmt`
- `forge test --offline`
- `cd services/rfq && npm test`
- 통과한 검증:
- `forge test --offline` 133/133 유지(이번 task는 코드 변경 없음)
- RFQ service smoke check
- `docs/rfq-threat-model.md` 존재와 링크 무결성
- `forge test --offline` 195/195(pre-task 189 + 신규 6).
- recipe unit test로 9-element/version 2/always-applicable 확인.
- element family별 rejection(A-02 disallowed/unset, A-04, A-05, B-01)은 정확한
reasonCode로 assert.
- 남은 리스크:
- signer key custody와 operator key management(multisig/HSM/rotation)은 open
decision이다.
- partial fill, dealer inventory, shared dealer registry는 범위 밖이다.
- cancel-vs-fill race는 first-lander로 해소되며 cancel은 확정 전까지 best-effort다.
- RFQ router-path integration-test 시나리오는 deferred follow-up이다.
- ungated legacy mock element(A-01/A-03/QP)와 새 operator-gated element 사이
hardening divergence — follow-up으로 정렬.
- C-01 Lockup은 fixture-only mock acquisition source에 의존; production
acquisition/lot data source와 holding-period 활성화 default는 미결정.
- production data source(OFAC/ONCHAINID/ERC-165/EDGAR) 연결과 legal 활성화는
approval-gated로 열려 있다.
- engine은 direction-aware가 아니다(기존 문서화된 concern).
72 changes: 72 additions & 0 deletions src/compliance/elements/AssetClassification.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// 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 B-01-v1 Asset classification declaration (mock). Stands in for an
/// issuer/operator declaration that an asset is offered under a given
/// regulation path (e.g. Reg D) — a settable per-asset tag stands in for
/// a real EDGAR/offering-document lookup. ASSET-side check: it inspects
/// `asset`, not `user` (contrast with the investor-attribute elements).
contract AssetClassification is BaseElement, Governed {
bytes32 internal constant ELEMENT_ID = "B-01-v1";

/// @dev Thrown when the contract is deployed with a zero required
/// classification, which would otherwise make an unclassified asset
/// (default bytes32(0)) indistinguishable from a correctly declared one.
error ZeroRequiredClassification();

/// @notice The classification tag every asset must carry to pass `check`.
bytes32 public immutable requiredClassification;

/// @notice asset => declared classification tag (bytes32(0) = unclassified).
mapping(address => bytes32) public classificationOf;

event ClassificationSet(address indexed asset, bytes32 classification);

constructor(bytes32 requiredClassification_)
BaseElement(ElementMetadata({
elementId: ELEMENT_ID,
category: ElementCategory.ASSET_ATTRIBUTE,
version: "B-01-v1",
temporal: TemporalNature.ONE_TIME,
decidability: Decidability.DETERMINISTIC,
timing: ObligationTiming.AT_TRADE_GATE,
statefulness: Statefulness.STATELESS
}))
{
if (requiredClassification_ == bytes32(0)) {
revert ZeroRequiredClassification();
}
requiredClassification = requiredClassification_;
}

/// @notice Declares (or clears) the classification tag for `asset`.
function setClassification(address asset, bytes32 classification) external onlyOperator {
classificationOf[asset] = classification;
emit ClassificationSet(asset, classification);
}

/// @dev Passes iff `asset`'s declared classification matches
/// `requiredClassification`. `user` is ignored — this is an
/// asset-side, not investor-side, check.
function check(address, address, address asset, uint256, bytes calldata)
external
view
override
returns (bool passed, bytes32 reasonCode)
{
passed = classificationOf[asset] == requiredClassification;
reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1);
}
}
70 changes: 70 additions & 0 deletions src/compliance/elements/Erc3643Native.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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 B-02-v1 ERC-3643 (T-REX) native asset attestation (mock, ASSET-side).
/// Internal architecture decision: regulated assets must be ERC-3643
/// (T-REX) native tokens. Production seam: this attestation would be
/// replaced by ERC-165 introspection against the T-REX `IToken`
/// interface ID, or a lookup in an on-chain token registry. An operator
/// attestation stands in here because the vendored T-REX token's
/// ERC-165 support is not guaranteed, and a false negative from a live
/// introspection check would brick settlement for a legitimately
/// ERC-3643-native asset. Fail-closed: an unattested asset fails.
contract Erc3643Native is BaseElement, Governed {
bytes32 internal constant ELEMENT_ID = "B-02-v1";

/// @notice asset => attested ERC-3643-native.
mapping(address => bool) public erc3643Native;

event Erc3643NativeSet(address indexed asset, bool native_);

constructor()
BaseElement(ElementMetadata({
elementId: ELEMENT_ID,
category: ElementCategory.ASSET_ATTRIBUTE,
version: "B-02-v1",
temporal: TemporalNature.ONE_TIME,
// DETERMINISTIC (not ATTESTATION_BASED): the production form of this
// check is ERC-165/token-registry introspection — an onchain-decidable
// fact. The operator attestation is only the illustrative stand-in.
decidability: Decidability.DETERMINISTIC,
timing: ObligationTiming.AT_TRADE_GATE,
statefulness: Statefulness.STATELESS
}))
{}

/// @notice Attest whether `asset` is an ERC-3643-native (T-REX) token.
/// @dev Production seam: replace with an ERC-165 `supportsInterface`
/// check against the T-REX `IToken` interface ID, or a lookup in a
/// token/ERC-3643 trusted-token registry.
function setErc3643Native(address asset, bool native_) external onlyOperator {
erc3643Native[asset] = native_;
emit Erc3643NativeSet(asset, native_);
}

/// @dev ASSET-side check: ignores `user`/`counterparty`/`amount`/`context`.
/// Passes iff `asset` has been attested ERC-3643-native. Unattested
/// assets fail closed.
function check(address, address, address asset, uint256, bytes calldata)
external
view
override
returns (bool passed, bytes32 reasonCode)
{
passed = erc3643Native[asset];
// recipeId 0 is a placeholder; the engine re-encodes with the real recipeId.
reasonCode = passed ? bytes32(0) : ReasonCodes.encode(0, ELEMENT_ID, 1);
}
}
Loading
Loading