Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ passing
nonce-scoped idempotent cancel).
- Non-goals: partial fill, dealer inventory, signer key custody, shared dealer
registry.
- Deferred follow-up: router-path maker-approval/cancellation integration-test
시나리오.
- Deferred follow-up (완료): router-path maker-approval/cancellation
integration-test 시나리오를 `test/integration/RFQFlow.t.sol`에서 real ERC-3643
스택 위 protected path로 커버한다.

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

Expand Down
13 changes: 7 additions & 6 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,21 @@ source of truth로 사용한다.
- legacy mock element(Sanctions A-01, AccreditedInvestor A-03, QualifiedPurchaser
A-13)의 attestation setter를 `Governed`/`onlyOperator` + 이벤트로 정렬해 CMP-001
이후 hardening divergence를 닫았다(Lockup C-01은 settable mutator가 없어 변경 없음).
- RFQ integration: RFQ 벤처를 protected router path(`ExecutionRouter → ComplianceEngine
→ RFQAdapter`)로 real ERC-3643 스택 위에서 end-to-end 커버(`test/integration/RFQFlow.t.sol`,
fill/maker-unapproved/cancel/non-compliant/direct-call bypass 5 시나리오) — RFQ-002 deferred follow-up 완료.

## Blocked

- 없음

## Next

1. RFQ integration-test 시나리오(router-path maker-approval/cancellation coverage)를
추가한다 — RFQ-002에서 deferred된 follow-up.
2. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다
1. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다
(C-01 Lockup은 현재 fixture-only mock acquisition source).
3. live Anvil deployment/E2E를 추가한다.
4. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
5. CI hardening(static analysis 등)을 강화한다.
2. live Anvil deployment/E2E를 추가한다.
3. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
4. CI hardening(static analysis 등)을 강화한다.

## Last Session Summary

Expand Down
230 changes: 230 additions & 0 deletions test/integration/RFQFlow.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;

import {IntegrationBase} from "./IntegrationBase.sol";

import {RFQAdapter} from "../../src/execution/adapters/rfq/RFQAdapter.sol";
import {RFQQuote} from "../../src/execution/adapters/rfq/RFQTypes.sol";

import {ExecutionRequest} from "../../src/types/ExecutionTypes.sol";
import {
ComplianceContext,
ComplianceDecision,
ManifestCore,
VenueType,
FlowType
} from "../../src/types/ComplianceTypes.sol";
import {VenueConfig, CustodyModel} from "../../src/types/VenueTypes.sol";
import {Errors} from "../../src/libraries/Errors.sol";

/// @title RFQFlow (integration)
/// @notice Drives the RFQ venue end-to-end through the PROTECTED router path
/// (`ExecutionRouter -> ComplianceEngine -> RFQAdapter`) against the real
/// ERC-3643 stack. The RFQ venue is fully unit-covered
/// (`test/unit/execution/RFQAdapter.t.sol`) but only ever against a
/// `MockComplianceEngine`; this file is the deferred RFQ-002 follow-up
/// that exercises the venue with the live engine + T-REX token.
///
/// Topology of an RFQ fill (maker SELLS the RWA):
/// - taker = alice: pays QUOTE (UNREGULATED) in, receives RWA out.
/// - maker = a dealer holding real T-REX RWA, KYC-verified, EIP-712 signer.
/// - tokenIn = QUOTE, tokenOut = RWA(ACTIVE). The regulated side is tokenOut,
/// so the decision's `allowedVenueTypes` mask == the RWA manifest's
/// `supportedEngines`. The RFQ bit (1 << VenueType.RFQ) must be set there for
/// `VenueSelector.validate` to admit the venue — see {_admitRFQVenueType}.
/// - settlement is non-custodial + maker<->taker direct: QUOTE taker->maker and
/// the REAL ERC-3643 RWA transfer maker->taker (genuine isVerified/canTransfer).
contract RFQFlowTest is IntegrationBase {
// Mirror of RFQAdapter.RFQFilled for expectEmit.
event RFQFilled(
bytes32 indexed quoteHash, address indexed maker, address indexed taker, uint256 amountIn, uint256 amountOut
);

// Engine bit for the RFQ venue type (bit 2). Sibling of {ENGINES_AMM}; kept
// local because only the RFQ path advertises it.
uint8 internal constant ENGINES_RFQ = uint8(1 << uint256(VenueType.RFQ));

// Deterministic maker signing key (distinct from alice = 0xA11CE).
uint256 internal constant MAKER_PK = uint256(keccak256("CORNER_STORE.RFQ_MAKER"));

// RFQ venue is a pure label (non-custodial): target/operator zero, custody NONE.
address internal constant RFQ_VENUE = address(0xF00D);

// QUOTE in (cash) / RWA out (regulated) for a single fill.
uint256 internal constant QUOTE_IN = 100 ether;
uint256 internal constant RWA_OUT = 250 ether;

address internal maker;
RFQAdapter internal rfqAdapter;

function setUp() public {
deployStack(); // full stack; RWA manifest lands AMM-only.
_admitRFQVenueType(); // re-declare the RWA manifest so the mask admits RFQ.

maker = vm.addr(MAKER_PK);

// RFQ adapter shares the router; authenticate it and approve the maker.
rfqAdapter = new RFQAdapter();
rfqAdapter.setRouter(address(router));
rfqAdapter.setMakerApproved(maker, true);

venueReg.registerVenue(
RFQ_VENUE,
VenueConfig({
venueType: VenueType.RFQ,
adapter: address(rfqAdapter),
target: address(0),
operator: address(0),
custody: CustodyModel.NONE,
active: true
})
);

// taker (alice): fully-compliant investor + QUOTE funding, approves the adapter.
setupBuyer(alice);
quote.mint(alice, 1_000 ether);
vm.prank(alice);
quote.approve(address(rfqAdapter), type(uint256).max);

// maker: KYC-verified real T-REX holder of RWA, approves the adapter to
// deliver RWA out. NOT engine-attested: the engine screens ctx.buyer only.
verifyInvestor(maker);
mint(maker, 1_000 ether);
vm.prank(maker);
rwaToken.approve(address(rfqAdapter), type(uint256).max);
}

/// @dev Re-declare the RWA manifest so its `supportedEngines` advertises RFQ
/// (in addition to AMM). Uses only existing product surfaces: the
/// TokenPolicyRegistry lifecycle (retire -> re-register -> approve) and
/// the manifest `supportedEngines` bitfield. The engine maps that field
/// 1:1 into `ComplianceDecision.allowedVenueTypes`, which is what
/// `VenueSelector.validate` checks against `VenueType.RFQ`.
function _admitRFQVenueType() internal {
ManifestCore memory m = _activeManifest(0, 0);
m.supportedEngines = ENGINES_AMM | ENGINES_RFQ;
policyReg.retireManifest(address(rwaToken), bytes32("re-engine-rfq"));
policyReg.registerManifest(address(rwaToken), m);
policyReg.approveManifest(address(rwaToken));
}

// --- helpers ----------------------------------------------------------

function _quote(uint256 nonce, uint64 expiry) internal view returns (RFQQuote memory q) {
q.maker = maker;
q.taker = alice;
q.tokenIn = address(quote);
q.tokenOut = address(rwaToken);
q.amountIn = QUOTE_IN;
q.amountOut = RWA_OUT;
q.venue = RFQ_VENUE;
q.nonce = nonce;
q.expiry = expiry;
}

function _sign(RFQQuote memory q) internal view returns (bytes memory) {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(MAKER_PK, rfqAdapter.hashQuote(q));
return abi.encodePacked(r, s, v);
}

/// @dev Build a router request whose context mirrors the signed quote exactly
/// (the adapter's `_validateQuote` rejects any field mismatch).
function _buildRfqRequest(RFQQuote memory q) internal returns (ExecutionRequest memory req) {
ComplianceContext memory ctx;
ctx.initiator = alice;
ctx.buyer = alice;
ctx.seller = maker;
ctx.tokenIn = address(quote);
ctx.tokenOut = address(rwaToken);
ctx.amountIn = q.amountIn;
ctx.amountOut = q.amountOut;
ctx.venueType = VenueType.RFQ;
ctx.venue = RFQ_VENUE;
ctx.flowType = FlowType.SECONDARY_TRADE;

req.context = ctx;
req.amountOutMin = q.amountOut;
req.deadline = uint64(block.timestamp + 1 hours);
req.nonce = nextNonce++;
req.venueData = abi.encode(q, _sign(q));
}

function _validRequest(uint256 quoteNonce) internal returns (RFQQuote memory q, ExecutionRequest memory req) {
q = _quote(quoteNonce, uint64(block.timestamp + 1 hours));
req = _buildRfqRequest(q);
}

// --- scenarios --------------------------------------------------------

/// @notice Full protected-path fill: both settlement legs move and the
/// adapter emits RFQFilled.
function test_rfqFill_succeedsForApprovedMaker() public {
(RFQQuote memory q, ExecutionRequest memory req) = _validRequest(1);

vm.expectEmit(true, true, true, true, address(rfqAdapter));
emit RFQFilled(rfqAdapter.hashQuote(q), maker, alice, QUOTE_IN, RWA_OUT);

vm.prank(alice);
router.execute(req);

// QUOTE leg: taker -> maker.
assertEq(quote.balanceOf(alice), 1_000 ether - QUOTE_IN, "taker paid QUOTE");
assertEq(quote.balanceOf(maker), QUOTE_IN, "maker received QUOTE");
// RWA leg (real ERC-3643): maker -> taker.
assertEq(rwaToken.balanceOf(maker), 1_000 ether - RWA_OUT, "maker delivered RWA");
assertEq(rwaToken.balanceOf(alice), RWA_OUT, "taker received RWA");
// Non-custodial: the adapter retains nothing.
assertEq(quote.balanceOf(address(rfqAdapter)), 0, "adapter holds no QUOTE");
assertEq(rwaToken.balanceOf(address(rfqAdapter)), 0, "adapter holds no RWA");
}

/// @notice An unapproved maker is rejected at settlement even with a valid
/// signature and a compliant taker.
function test_rfqFill_revertsWhenMakerUnapproved() public {
rfqAdapter.setMakerApproved(maker, false);
(, ExecutionRequest memory req) = _validRequest(1);

vm.prank(alice);
vm.expectRevert(Errors.RFQMakerNotApproved.selector);
router.execute(req);
}

/// @notice A maker-cancelled quote nonce can no longer be filled.
function test_rfqFill_revertsAfterMakerCancels() public {
(, ExecutionRequest memory req) = _validRequest(1);

vm.prank(maker);
rfqAdapter.cancelQuoteNonce(1);

vm.prank(alice);
vm.expectRevert(Errors.RFQQuoteUsed.selector);
router.execute(req);
}

/// @notice Flipping ONE investor attestation (jurisdiction) makes the live
/// engine reject before any settlement. `ComplianceRejected(bytes32)`
/// is parameterized, so the selector is matched partially.
function test_rfqFill_revertsWhenBuyerNotCompliant() public {
// alice remains a verified holder and otherwise-compliant investor; only
// her jurisdiction is moved to a disallowed code.
jurisdiction.setJurisdiction(alice, bytes32("ZZ"));
(, ExecutionRequest memory req) = _validRequest(1);

vm.prank(alice);
vm.expectPartialRevert(Errors.ComplianceRejected.selector);
router.execute(req);

assertEq(rwaToken.balanceOf(alice), 0, "no RWA delivered on compliance rejection");
assertEq(quote.balanceOf(maker), 0, "no QUOTE moved on compliance rejection");
}

/// @notice Calling the adapter directly (bypassing the router) reverts: only
/// the wired router may drive settlement.
function test_rfqAdapter_directCallReverts() public {
(, ExecutionRequest memory req) = _validRequest(1);
ComplianceDecision memory d;

vm.expectRevert(Errors.NotAuthorized.selector);
rfqAdapter.execute(req, d);
}
}
Loading