diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4838c615..74ca24dd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,8 +12,8 @@ env:
FOUNDRY_PROFILE: ci
jobs:
- unit:
- name: Unit tests
+ size:
+ name: Build sizes
runs-on: ubuntu-latest
environment: test
steps:
@@ -30,6 +30,21 @@ jobs:
- name: Build
run: forge build --use solc:0.8.28 --via-ir --optimize --optimizer-runs 15 --sizes
+ unit:
+ name: Unit tests
+ runs-on: ubuntu-latest
+ environment: test
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Install Foundry
+ uses: foundry-rs/foundry-toolchain@v1
+
+ - name: Show Forge version
+ run: forge --version
+
- name: Unit tests
run: forge test --use solc:0.8.28 --via-ir --optimize --optimizer-runs 15 -vvv --nmc '(ForkTest|AdhocTest)'
@@ -48,9 +63,6 @@ jobs:
- name: Show Forge version
run: forge --version
- - name: Build
- run: forge build --use solc:0.8.28 --via-ir --optimize --optimizer-runs 15 --sizes
-
- name: Fork tests
env:
ALCHEMY_API_KEY: "${{ secrets.ALCHEMY_API_KEY }}"
diff --git a/script/upgrade-legacy-issuance-managers.s.sol b/script/upgrade-legacy-issuance-managers.s.sol
index 1eae06dc..37d44c89 100644
--- a/script/upgrade-legacy-issuance-managers.s.sol
+++ b/script/upgrade-legacy-issuance-managers.s.sol
@@ -1,99 +1,100 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
-import {Script} from "forge-std/Script.sol";
-import {Test, console2} from "forge-std/Test.sol";
-import {CyberCorpFactory} from "../src/CyberCorpFactory.sol";
-import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
-import {IIssuanceManager} from "../src/interfaces/IIssuanceManager.sol";
-import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
-import {BorgAuth} from "../src/libs/auth.sol";
-import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
-import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
-import {IDealManager} from "../src/interfaces/IDealManager.sol";
-import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
-import {CertificateDetails} from "../src/storage/CyberCertPrinterStorage.sol";
-import "../src/CyberCorpConstants.sol";
-import {CertificateUriBuilder} from "../src/CertificateUriBuilder.sol";
-import {SAFTExtension} from "../src/storage/extensions/SAFTExtension.sol";
-import {IssuanceManager} from "../src/IssuanceManager.sol";
-import {IssuanceManagerWithMigration} from "../src/IssuanceManagerWithMigration.sol";
-import {ILegacyFactory} from "./interfaces/ILegacyFactory.sol";
-import {KnownAddressesLoader} from "./libs/KnownAddressesLoader.sol";
-import {CyberCorp} from "../src/CyberCorp.sol";
-
-contract UpgradeLegacyIssuanceManagersScript is Script {
- function run() public returns (IssuanceManagerWithMigration) {
- return runWithArgs(type(uint256).max);
- }
-
- function runWithArgs(uint256 maxCount) public returns (IssuanceManagerWithMigration) {
- bytes32 salt = bytes32(keccak256("MetaLexCyberCorp.PublicRounds.UpgradeV3.0.2"));
- uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY_MAIN");
-
- CyberCorpFactory cyberCorpFactory = CyberCorpFactory(0x51413048f3Dfc4516e95BC8e249341B1D53B6cB2);
- IssuanceManagerFactory imFactoryV2 = IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()); // this is the v2 one (with reference implementation)
-
- // CyberCertPrinter beacons are owned by each individual IssuanceManagers, so to upgrade them we must
- // enumerate all existing IssuanceManager addresses and their corresponding factories (https://dune.com/queries/6129394):
- // - 0xA32547aAdAA4975082D729c79e79dBaE4385EBCf
- // - 0xade5d9fBaC6201535dc558FBD247e6859f5aa8C5 (deprecated, won't touch it)
- ILegacyFactory legacyImFactory = ILegacyFactory(0xA32547aAdAA4975082D729c79e79dBaE4385EBCf);
-
- // Load all known cyber corps
- address[] memory knownCyberCorps = KnownAddressesLoader.load(block.chainid, "/script/res/known-cyber-corps.json", maxCount);
-
- vm.startBroadcast(deployerPrivateKey);
-
- //
- // Upgrade legacy DealManagers
- //
-
- // Upgrade beacon implementation to the new implementation (with migration feature)
-
- IssuanceManagerWithMigration imWithMigrationImpl = new IssuanceManagerWithMigration();
-
- // Expect new factory to be deployed at a predetermined address because we will hard-code it to the migration contract
- vm.assertEq(cyberCorpFactory.issuanceManagerFactory(), imWithMigrationImpl.NEW_UPGRADE_FACTORY(), "new issuanceManagerFactory address has changed, update it in IssuanceManagerWithMigration");
-
- // Upgrade beacon to a special implementation with migration features
- legacyImFactory.upgradeImplementation(address(imWithMigrationImpl));
- vm.assertEq(legacyImFactory.getBeaconImplementation(), address(imWithMigrationImpl), "beacon implementation should be upgraded with migration features by now");
- console2.log("Set new beacon implementation (with migration features): %s for legacy IssuanceManagerFactory: %s", address(imWithMigrationImpl), address(legacyImFactory));
-
- // This is the ugly part: One-time manual upgrade required for legacy DealManagers.
- // This section updates the `upgradeFactory` pointer to the new permanent factory address,
- // enabling access to updated fee-related methods. This migration is performed one-by-one
- // for each legacy IssuanceManager contract.
-
- // This is a ONE-TIME operation per legacy IssuanceManager's lifetime. Once updated,
- // the `upgradeFactory` is expected to remain permanent and unchanged for all following upgrades.
- for (uint256 i = 0; i < knownCyberCorps.length; i++) {
- address imAddr = CyberCorp(knownCyberCorps[i]).issuanceManager();
- IssuanceManagerWithMigration(imAddr).migrateUpgradeFactory();
- vm.assertNotEq(IssuanceManagerFactory(IssuanceManager(imAddr).getUpgradeFactory()).getRefImplementation(), address(0), "should be able to lookup reference implementation now");
- vm.assertEq(
- IssuanceManager(imAddr).getCertPrinterBeaconImplementation(),
- IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()).getCyberCertPrinterRefImplementation(),
- "should point CyberCertPrinter implementation to reference now"
- );
- vm.assertEq(
- IssuanceManager(imAddr).getScripBeaconImplementation(),
- IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()).getCyberScripRefImplementation(),
- "should point CyberScrip implementation to reference now"
- );
- console2.log("Migrated legacy IssuanceManager: %s", imAddr);
- }
-
- // Upgrade beacon to the normal implementation since migration is done
- address refImplementation = imFactoryV2.getRefImplementation();
- legacyImFactory.upgradeImplementation(refImplementation);
- vm.assertEq(legacyImFactory.getBeaconImplementation(), refImplementation, "beacon implementation should be upgraded without migration features by now");
- console2.log("Set new beacon implementation (without migration features): %s for legacy IssuanceManagerFactory: %s", address(refImplementation), address(legacyImFactory));
-
- vm.stopBroadcast();
-
- return imWithMigrationImpl;
- }
-}
+// TODO commented out for now due to contract size violation
+//import {Script} from "forge-std/Script.sol";
+//import {Test, console2} from "forge-std/Test.sol";
+//import {CyberCorpFactory} from "../src/CyberCorpFactory.sol";
+//import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
+//import {IIssuanceManager} from "../src/interfaces/IIssuanceManager.sol";
+//import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+//import {BorgAuth} from "../src/libs/auth.sol";
+//import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
+//import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+//import {IDealManager} from "../src/interfaces/IDealManager.sol";
+//import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+//import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+//import {CertificateDetails} from "../src/storage/CyberCertPrinterStorage.sol";
+//import "../src/CyberCorpConstants.sol";
+//import {CertificateUriBuilder} from "../src/CertificateUriBuilder.sol";
+//import {SAFTExtension} from "../src/storage/extensions/SAFTExtension.sol";
+//import {IssuanceManager} from "../src/IssuanceManager.sol";
+//import {IssuanceManagerWithMigration} from "../src/IssuanceManagerWithMigration.sol";
+//import {ILegacyFactory} from "./interfaces/ILegacyFactory.sol";
+//import {KnownAddressesLoader} from "./libs/KnownAddressesLoader.sol";
+//import {CyberCorp} from "../src/CyberCorp.sol";
+//
+//contract UpgradeLegacyIssuanceManagersScript is Script {
+// function run() public returns (IssuanceManagerWithMigration) {
+// return runWithArgs(type(uint256).max);
+// }
+//
+// function runWithArgs(uint256 maxCount) public returns (IssuanceManagerWithMigration) {
+// bytes32 salt = bytes32(keccak256("MetaLexCyberCorp.PublicRounds.UpgradeV3.0.2"));
+// uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY_MAIN");
+//
+// CyberCorpFactory cyberCorpFactory = CyberCorpFactory(0x51413048f3Dfc4516e95BC8e249341B1D53B6cB2);
+// IssuanceManagerFactory imFactoryV2 = IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()); // this is the v2 one (with reference implementation)
+//
+// // CyberCertPrinter beacons are owned by each individual IssuanceManagers, so to upgrade them we must
+// // enumerate all existing IssuanceManager addresses and their corresponding factories (https://dune.com/queries/6129394):
+// // - 0xA32547aAdAA4975082D729c79e79dBaE4385EBCf
+// // - 0xade5d9fBaC6201535dc558FBD247e6859f5aa8C5 (deprecated, won't touch it)
+// ILegacyFactory legacyImFactory = ILegacyFactory(0xA32547aAdAA4975082D729c79e79dBaE4385EBCf);
+//
+// // Load all known cyber corps
+// address[] memory knownCyberCorps = KnownAddressesLoader.load(block.chainid, "/script/res/known-cyber-corps.json", maxCount);
+//
+// vm.startBroadcast(deployerPrivateKey);
+//
+// //
+// // Upgrade legacy DealManagers
+// //
+//
+// // Upgrade beacon implementation to the new implementation (with migration feature)
+//
+// IssuanceManagerWithMigration imWithMigrationImpl = new IssuanceManagerWithMigration();
+//
+// // Expect new factory to be deployed at a predetermined address because we will hard-code it to the migration contract
+// vm.assertEq(cyberCorpFactory.issuanceManagerFactory(), imWithMigrationImpl.NEW_UPGRADE_FACTORY(), "new issuanceManagerFactory address has changed, update it in IssuanceManagerWithMigration");
+//
+// // Upgrade beacon to a special implementation with migration features
+// legacyImFactory.upgradeImplementation(address(imWithMigrationImpl));
+// vm.assertEq(legacyImFactory.getBeaconImplementation(), address(imWithMigrationImpl), "beacon implementation should be upgraded with migration features by now");
+// console2.log("Set new beacon implementation (with migration features): %s for legacy IssuanceManagerFactory: %s", address(imWithMigrationImpl), address(legacyImFactory));
+//
+// // This is the ugly part: One-time manual upgrade required for legacy DealManagers.
+// // This section updates the `upgradeFactory` pointer to the new permanent factory address,
+// // enabling access to updated fee-related methods. This migration is performed one-by-one
+// // for each legacy IssuanceManager contract.
+//
+// // This is a ONE-TIME operation per legacy IssuanceManager's lifetime. Once updated,
+// // the `upgradeFactory` is expected to remain permanent and unchanged for all following upgrades.
+// for (uint256 i = 0; i < knownCyberCorps.length; i++) {
+// address imAddr = CyberCorp(knownCyberCorps[i]).issuanceManager();
+// IssuanceManagerWithMigration(imAddr).migrateUpgradeFactory();
+// vm.assertNotEq(IssuanceManagerFactory(IssuanceManager(imAddr).getUpgradeFactory()).getRefImplementation(), address(0), "should be able to lookup reference implementation now");
+// vm.assertEq(
+// IssuanceManager(imAddr).getCertPrinterBeaconImplementation(),
+// IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()).getCyberCertPrinterRefImplementation(),
+// "should point CyberCertPrinter implementation to reference now"
+// );
+// vm.assertEq(
+// IssuanceManager(imAddr).getScripBeaconImplementation(),
+// IssuanceManagerFactory(cyberCorpFactory.issuanceManagerFactory()).getCyberScripRefImplementation(),
+// "should point CyberScrip implementation to reference now"
+// );
+// console2.log("Migrated legacy IssuanceManager: %s", imAddr);
+// }
+//
+// // Upgrade beacon to the normal implementation since migration is done
+// address refImplementation = imFactoryV2.getRefImplementation();
+// legacyImFactory.upgradeImplementation(refImplementation);
+// vm.assertEq(legacyImFactory.getBeaconImplementation(), refImplementation, "beacon implementation should be upgraded without migration features by now");
+// console2.log("Set new beacon implementation (without migration features): %s for legacy IssuanceManagerFactory: %s", address(refImplementation), address(legacyImFactory));
+//
+// vm.stopBroadcast();
+//
+// return imWithMigrationImpl;
+// }
+//}
diff --git a/specs/analysis/conditions.md b/specs/analysis/conditions.md
new file mode 100644
index 00000000..c6dfffaa
--- /dev/null
+++ b/specs/analysis/conditions.md
@@ -0,0 +1,181 @@
+# cyberTRADE — Conditions Reference
+
+**Stage:** `threshold` = checked at `postOffer` and `acceptOffer` (gates contract formation) and re-checked at
+finalization (gates asset transfer) · `closing` = checked at finalization only, gates asset transfer
+
+**`data` encoding:** All threshold conditions receive `data = abi.encode(offerAgreementId)`. There is
+no `partyAddr` in `data`. Each condition derives party addresses and all other context by calling
+`IDealManager(_contract).getOffer(offerAgreementId)` — no `LexScroWLite` dependency. The `Parties`
+column below is descriptive (what role the condition validates internally); it is not a DealManager
+dispatch mechanism.
+
+The `offerAgreementId` is a stable DealManager-internal key that is constant across all partial fills
+of the same offer (one offer → many settlement agreements). Threshold conditions always refer to the
+offer; closing conditions receive `abi.encode(settlementAgreementId)` and refer to the specific lot.
+
+| Condition | Stage | Parties | Offer fields used |
+|----------------------------------------|---------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------|
+| `KYCAMLCondition` | threshold | buyer + seller | `offeror`, buyer via `settlementAgreementIds` |
+| `AccreditedInvestorCondition` | threshold | buyer | buyer via `settlementAgreementIds` |
+| `QualifiedPurchaserCondition` | threshold | buyer + seller | `offeror`, buyer via `settlementAgreementIds` |
+| `QualifiedInstitutionalBuyerCondition` | threshold | buyer | buyer via `settlementAgreementIds` |
+| `NonUSNationalityCondition` | threshold | buyer | buyer via `settlementAgreementIds` |
+| `USStateOfResidenceCondition` | threshold | buyer | buyer via `settlementAgreementIds`, `spvAddress` |
+| `TaxInfoCondition` | threshold | buyer + seller | `offeror`, buyer via `settlementAgreementIds` |
+| `LegionSoulboundCondition` | threshold | buyer + seller | `offeror`, buyer via `settlementAgreementIds` |
+| ~~`AgreementSignedCondition`~~ | ~~threshold~~ | ~~buyer + seller~~ | _(dropped — agreement creation is deferred to acceptOffer; signing IS acceptance, so checking "is it signed" is tautological)_ |
+| `Section4a7DisclosureCondition` | threshold | buyer | buyer via `settlementAgreementIds`, `spvAddress` |
+| `HoldingPeriodCondition` | threshold | seller | `offeror`, `certPrinter`, `tokenId` |
+| `Rule144DisclosureCondition` | threshold | — | `spvAddress` |
+| `LegalOpinionCondition` | threshold | — | `spvAddress` |
+| `RegSDistributionComplianceCondition` | threshold | buyer | `counterparty`, `spvAddress` |
+| `HolderCapCondition` — §3(c)(1) | threshold | buyer | `counterparty`, `spvAddress` |
+| `HolderCapCondition` — §3(c)(1)(C) | threshold | buyer | `counterparty`, `spvAddress` |
+| `HolderCapCondition` — Touche Remnant | threshold | buyer | `counterparty`, `spvAddress` |
+| `CFIUSCondition` | threshold | buyer | `counterparty`, `spvAddress` |
+| `GPLPApprovalCondition` | threshold | — | `spvAddress` |
+| `GPConsentCondition` | threshold | — | `spvAddress` |
+| `QMSModeCondition` | threshold | — | `spvAddress` |
+| `ERISACondition` | threshold | buyer | buyer via `settlementAgreementIds` |
+| `GlobalKillCondition` | closing | — | _(no Offer lookup; checks kill-switch state)_ |
+| `TimeSettlementPeriodCondition` | closing | — | _(no Offer lookup; checks settlement timestamp)_ |
+
+---
+
+## Evaluation Rules
+
+### Condition call protocol (threshold conditions)
+
+DealManager calls each condition once:
+
+```solidity
+condition.checkCondition(address(this), msg.sig, abi.encode(offerAgreementId))
+```
+
+The condition decodes `offerAgreementId`, calls `IDealManager(_contract).getOffer(offerAgreementId)`,
+and derives whatever it needs — `offeror`, `spvAddress`, `certPrinter`, `tokenId` — from the returned
+`Offer` struct. There is no per-party dispatch loop in DealManager; the condition owns all
+party-resolution logic internally.
+
+**Resolving the buyer address:** `Offer` has no `counterparty` field — one offer can have many
+acceptors (partial fills). Buyer-facing conditions instead check `offer.settlementAgreementIds`:
+
+- `length == 0` → posting context, no buyer yet → short-circuit to `true`
+- `length > 0` → acceptance context →
+ `buyer = IDealManager(_contract).getEscrowDetails(offer.settlementAgreementIds[offer.settlementAgreementIds.length - 1]).counterParty`
+
+### Two-array lifecycle
+
+Both sets are owner-managed DealManager config (never offeror-supplied). At `postOffer` they are resolved
+and **snapshotted onto the offer** — so an offer is governed by the rules in effect when it was posted —
+and stored on the secondary-trade record itself (self-contained; no dependency on the primary-deal escrow
+library's `conditionsByEscrow`):
+
+| Array | Evaluated at | Entry points |
+|-----------------------------|------------------------------------|------------------------------------------------------------------|
+| `offer.thresholdConditions` | Offer posted, accepted, finalized | `postOffer`, `acceptOffer`, `finalizeSecondaryTradeAgreement` |
+| `offer.closingConditions` | Finalization | `finalizeSecondaryTradeAgreement` |
+
+Every condition in the array is walked in sequence at each entry point. Any failure reverts immediately.
+Snapshotting the addresses does not blunt the kill switch: `GlobalKillCondition` reads its live state
+internally, so a switch raised after posting still halts an in-flight settlement at finalize. Likewise,
+re-running the threshold set at finalize means eligibility lost after acceptance (revoked credential,
+breached holder cap, blocked-state move, withdrawn approval) blocks the asset transfer.
+
+### Within threshold: posting vs. acceptance
+
+DealManager calls `checkCondition` identically at `postOffer` and `acceptOffer`. Whether a condition
+enforces at posting depends on its internal logic and whether `offer.counterparty` is set:
+
+| Parties | At posting (`settlementAgreementIds.length == 0`) | At acceptance |
+|----------------|-----------------------------------------------------|--------------------|
+| buyer + seller | Seller-side enforces; buyer-side must return `true` | Both sides enforce |
+| seller | **Enforces** — `offer.offeror` is known | Re-evaluated |
+| buyer | **Must return `true`** — no buyer exists yet | Enforces |
+| — | Enforces | Enforces |
+
+Buyer-facing conditions detect posting context by checking `offer.settlementAgreementIds.length == 0`
+and short-circuit to `true`. DealManager calls every condition in the array at every evaluation
+point without filtering.
+
+## DealManager Configuration
+
+### Two condition sets (§4.1.5)
+
+DealManager holds two distinct condition sets, both owner-managed and snapshotted onto the offer at `postOffer`:
+
+| Set | Scope | Snapshotted onto offer? | Default contents |
+|-------------------------|-------------------------------|-------------------------|--------------------------------------------------------|
+| Closing-condition set | Every offer | Yes — at `postOffer` | `GlobalKillCondition`, `TimeSettlementPeriodCondition` |
+| Threshold-condition set | Every offer (two §7.2 layers) | Yes — at `postOffer` | See below |
+
+The closing-condition set is copied onto the offer at `postOffer` and evaluated at finalization (gating asset
+transfer). The threshold-condition set is resolved (fund-specific (§6) ++ exemption-specific (§5)) and copied onto the
+offer at `postOffer`, then evaluated at `postOffer` and re-evaluated at `acceptOffer` (gating contract formation)
+and again at finalization (gating asset transfer). Offerors supply only the exemption pathway, never condition addresses.
+
+### Default closing set
+
+Every DealManager gets `GlobalKillCondition` and `TimeSettlementPeriodCondition` as closing conditions:
+
+| Condition | Deployment | Admin |
+|---------------------------------|---------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
+| `GlobalKillCondition` | Deployed once at protocol initialization; shared across all Legion DealManagers | MetaLeX + Legion each hold one admin key; either can raise unilaterally; both required to lower |
+| `TimeSettlementPeriodCondition` | Deployed once; configured per-DealManager | Default delay: 24h from acceptance; reparameterized to 45-day gate from listing timestamp for QMS-mode SPVs |
+
+### Threshold set: two layers (§7.2)
+
+The threshold-condition set combines two layers per v3.53 §7.2. At `postOffer` they are resolved in the
+order fund-specific (Layer 2) ++ exemption-specific (Layer 1) and snapshotted onto the offer:
+
+| Layer | Where configured | When | Conditions |
+|-----------------------------------|-----------------------------------|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Layer 2 — Fund-specific (§6) | Individual `DealManager` | SPV onboarding; applies to every offer for the SPV | `KYCAMLCondition`, `TaxInfoCondition`, `ERISACondition`, `USStateOfResidenceCondition`, `HolderCapCondition`, `QualifiedPurchaserCondition`, `CFIUSCondition`, `LegionSoulboundCondition`, `GPLPApprovalCondition`, `QMSModeCondition` |
+| Layer 1 — Exemption-specific (§5) | Individual `DealManager` registry | Protocol initialization (addresses registered); selected per-offer at `postOffer` based on `exemptionPathway` | `HoldingPeriodCondition`, `Rule144DisclosureCondition`, `AccreditedInvestorCondition`, `Section4a7DisclosureCondition`, `LegalOpinionCondition`, `QualifiedInstitutionalBuyerCondition`, `NonUSNationalityCondition`, `RegSDistributionComplianceCondition` |
+
+#### Layer 2 — Fund-specific (§6) (individual `DealManager`, configured at SPV onboarding)
+
+Added to the individual DealManager during SPV onboarding and applied to every offer for that SPV. Only conditions
+applicable to the SPV are added. §7.2 classifies the baseline buyer-credential gates (the first four below) as
+fund-specific, so each SPV must register them explicitly — there is no platform-wide tier that injects them
+automatically.
+
+| Condition | When present | Parameterization |
+|-------------------------------|------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `KYCAMLCondition` | All SPVs / all paths | None |
+| `TaxInfoCondition` | All SPVs / all paths; blocks acceptance until W-9/W-8BEN recorded in LeXcheX | None |
+| `ERISACondition` | All U.S. pathways; silent for Reg S non-U.S. buyers | None |
+| `USStateOfResidenceCondition` | All SPVs | Issuer-configurable blocked-states list; **New York is on the default blocked-states list** for every SPV that has not registered under NY Martin Act Article 23-A, regardless of exemption pathway |
+| `HolderCapCondition` | All SPVs | ICA exception (`§3(c)(1)`, `§3(c)(1)(C)`, or `§3(c)(7)`); SPV domicile (for Touche Remnant U.S.-resident-only count); cap (100 / 250 / none) |
+| `QualifiedPurchaserCondition` | §3(c)(7) funds only | Parameterizes `LexChexCondition` for QP `investorType` |
+| `CFIUSCondition` | SPVs that do not satisfy the FIRRMA §800.307 fund exception | SPV CFIUS sensitivity flag; blocked jurisdictions |
+| `LegionSoulboundCondition` | Optional; GP-configurable | Soulbound credential category/tier required of buyer (and optionally seller) |
+| `GPLPApprovalCondition` | Optional; only if governing documents require per-deal approval | Authorized approver address (GP, managing member, or delegated compliance officer) |
+| `QMSModeCondition` | Optional; per-SPV opt-in for §1.7704-1(g) QMS safe harbor | Frequency cap value (counsel-determined per SPV); listing timestamp stored at `postOffer` |
+
+> Note: `AgreementSignedCondition` was previously listed in this baseline set but has been dropped.
+
+#### Layer 1 — Exemption-specific (§5) (individual `DealManager`, selected at `postOffer`)
+
+Condition contract addresses are registered in the DealManager (or a shared registry) at protocol initialization. At
+`postOffer`, DealManager reads the offer's `exemptionPathway` field and appends the corresponding subset to the
+threshold-condition array for that offer's `agreementId`. The same condition instances are shared across all SPVs.
+
+| Condition | Rule 144 | §4(a)(7) | §4(a)(1½) | Rule 144A | Reg S |
+|----------------------------------------|:--------:|:--------:|:---------:|:---------:|:-----:|
+| `HoldingPeriodCondition` | ✓ | — | — | — | — |
+| `Rule144DisclosureCondition` | ✓ | — | — | — | — |
+| `AccreditedInvestorCondition` | — | ✓ | optional | — | — |
+| `Section4a7DisclosureCondition` | — | ✓ | — | — | — |
+| `LegalOpinionCondition` | — | — | ✓ | — | — |
+| `QualifiedInstitutionalBuyerCondition` | — | — | — | ✓ | — |
+| `NonUSNationalityCondition` | — | — | — | — | ✓ |
+| `RegSDistributionComplianceCondition` | — | — | — | — | ✓ |
+
+### Deployment responsibility
+
+| Scope | Who | When |
+|-------------------------------------------------------------------|-----------------------------------------|--------------------------------------------------------------------------|
+| Layer 1 (exemption-specific) condition addresses | MetaLeX | Protocol initialization |
+| `GlobalKillCondition` + `TimeSettlementPeriodCondition` (closing) | MetaLeX | Protocol initialization; MetaLeX + Legion admin roles assigned at deploy |
+| Layer 2 (fund-specific) conditions | MetaLeX or Legion via factory contracts | SPV onboarding |
diff --git "a/specs/analysis/dealManager secondary trades \342\200\224 exemption path test coverage map.md" "b/specs/analysis/dealManager secondary trades \342\200\224 exemption path test coverage map.md"
new file mode 100644
index 00000000..8cc24a57
--- /dev/null
+++ "b/specs/analysis/dealManager secondary trades \342\200\224 exemption path test coverage map.md"
@@ -0,0 +1,96 @@
+# dealManager secondary trades — exemption path test coverage map
+
+Coverage map for `test/DealManagerSecondaryTradeExemptionPathwayTest.t.sol`, an integration test
+that drives a **full secondary trade (`post → accept → finalize`) through each exemption pathway**
+with the *real* secondary-trading conditions and the *real* `LeXcheXBadge` credential layer wired in.
+
+Pathway → condition mapping is grounded in `cyberTRADE Exemption Pathways v3.52.md`
+(§"Condition Contracts per Pathway"), verified against `cyberTRADE_spec_v3.55.dev0.md` §5 / §6.1–6.5
+and §4.1.4 — consistent, same five pathways and same per-pathway conditions.
+
+**Every condition in the map is now a real implementation from `src/libs/conditions/secondary/` —
+no in-file mocks remain.** The closing set (`GlobalKillCondition`, `TimeSettlementPeriodCondition`)
+is enforced for real on every pathway: each happy path warps past the 24h minimum settlement period
+between acceptance and finalization, and two dedicated tests exercise the closing conditions' own
+blocking behavior.
+
+**Latest run:** `forge test --use solc:0.8.28 --via-ir --optimize --optimizer-runs 15
+--match-contract DealManagerSecondaryTradeExemptionPathwayTest` →
+**7 passed / 0 failed** (5 pathway happy paths + 2 closing-condition tests).
+
+## Legend
+
+- ✓ real condition, enforced and passing
+- ○ real condition attached but auto-silent for this pathway (short-circuits to pass)
+- — not attached for this pathway
+
+## Scenario × condition
+
+All scenarios: SELL offer, full fill (100 units), expected terminal state **FINALIZED**. Distinct
+buyer per pathway. SPV-layer conditions apply to every pathway; pathway-layer conditions are keyed
+by `exemptionPathway`; closing conditions are evaluated at finalize (after a +24h warp to clear the
+settlement period).
+
+| Scenario (test fn) | Pathway | Buyer profile | Seller cert `acquisitionDate` | KYCAML | TaxInfo | HolderCap | ERISA | USState | Legion | AgreementSigned | HoldingPeriod | Accredited | QIB | NonUSPerson | RegSCompliance | Rule144Disc | §4a7Disc | LegalOpinion | GlobalKill | TimeSettlement |
+|---------------------------------|-----------------|------------------------------------------|-------------------------------|:------:|:-------:|:---------:|:-----:|:-------:|:------:|:---------------:|:-------------:|:----------:|:---:|:-----------:|:--------------:|:-----------:|:--------:|:------------:|:----------:|:--------------:|
+| `test_Rule144_HappyPath` | RULE_144 | US individual, state CA | > 365 d ago | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | — | ✓ | — | — | ✓ | ✓ |
+| `test_Section4a7_HappyPath` | SECTION_4A7 | US **accredited**, CA | any | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | — | — | — | ✓ | — | ✓ | ✓ |
+| `test_Section4a1Half_HappyPath` | SECTION_4A1HALF | US sophisticated (KYC only), CA | any | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — | — | — | — | — | ✓ | ✓ | ✓ |
+| `test_Rule144A_HappyPath` | RULE_144A | US **QIB**, CA | any | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | — | — | — | — | — | ✓ | ✓ |
+| `test_RegulationS_HappyPath` | REGULATION_S | **non-US person** (juris KY, no usState) | > compliance period ago | ✓ | ✓ | ✓ | ○ | ○ | ✓ | ✓ | — | — | — | ✓ | ✓ | — | — | — | ✓ | ✓ |
+
+**SPV-layer (all pathways):** KYCAML, TaxInfo, HolderCap, ERISA, USState, Legion, AgreementSigned.
+**Closing set (all):** GlobalKill, TimeSettlement.
+**Pathway-layer:** the columns between AgreementSigned and GlobalKill.
+
+## Closing-condition behavior tests
+
+| Test fn | What it proves |
+|-------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `test_GlobalKill_BlocksFinalize_UntilLowered` | Either admin raises unilaterally mid-deal → finalize reverts `SecondaryConditionsNotMet(globalKill)`; the proposer alone cannot confirm the lower (two-call, two-admin lowering); once the other admin confirms, finalize succeeds |
+| `test_TimeSettlement_BlocksEarlyFinalize` | `finalizableAt == acceptance + 24h`; finalize before the window reverts `SecondaryConditionsNotMet(timeSettlement)`; after the warp it succeeds |
+
+## Condition → coverage
+
+| Condition | Real? | Covered by | Notes |
+|---------------------------------------|:-----:|--------------------------------|-----------------------------------------------------------------------------------------------------------|
+| KYCAMLCondition | ✓ | all 5 | both buyer & seller hold a valid KYC_AML badge |
+| TaxInfoCondition | ✓ | all 5 | admin records `setTaxForm(buyer, W9, …)` |
+| HolderCapCondition | ✓ | all 5 | §3(c)(1), cap 100; buyer is a fresh holder (+1 ≤ 100) |
+| ERISACondition | ✓ | 144, 4a7, 4a1½, 144A (○ Reg S) | buyer's attestation recorded as a signer value on the settlement agreement |
+| USStateOfResidenceCondition | ✓ | 144, 4a7, 4a1½, 144A (○ Reg S) | buyer state CA (NY is default-blocked, unused); silent for the non-US Reg S buyer |
+| LegionSoulboundCondition | ✓ | all 5 | buyer holds the Legion custom-category credential |
+| AgreementSignedCondition | ✓ | all 5 (SPV-layer) | `registry.allPartiesSigned(settlementId)`; silent at posting, satisfied from acceptance onward |
+| HoldingPeriodCondition | ✓ | 144 | reads `FundInterestData.acquisitionDate` from the seller cert |
+| LexChexBadgeKind(ACCREDITED_INVESTOR) | ✓ | 4a7 | buyer-only |
+| LexChexBadgeKind(QIB) | ✓ | 144A | buyer-only |
+| LexChexBadgeKind(NON_US_PERSON) | ✓ | Reg S | buyer-only; approximates the spec's zkPassport `NonUSPersonCondition` (a generic `ICondition`, not typed) |
+| RegSDistributionComplianceCondition | ✓ | Reg S | `setRegSConfig(corp, 3, 365 d)`; reads acquisitionDate |
+| Rule144DisclosureCondition | ✓ | 144 | SPV admin records `setDisclosurePackage(corp, uri, asOf)`; 16-month freshness policy |
+| Section4a7DisclosureCondition | ✓ | 4a7 | package freshness (from posting) + buyer's acknowledgment-of-receipt signer value (from acceptance) |
+| LegalOpinionCondition | ✓ | 4a1½ | GP records `recordGPSignOff(dm, offerId)` between post and accept, pre-approving the offer's settlements |
+| GlobalKillCondition | ✓ | all 5 (closing) + kill test | plain singleton; two admin slots (MetaLeX + Legion), raise unilateral, lower two-call |
+| TimeSettlementPeriodCondition | ✓ | all 5 (closing) + timing test | 24h default from acceptance (reconstructed as `escrow.expiry − settlementWindow`); happy paths warp past |
+| CFIUSCondition | ✓ | none | implemented; optional per-SPV, out of scope for these happy paths |
+| GPLPApprovalCondition | ✓ | none | implemented; optional per-SPV, out of scope |
+
+## Test-fixture notes
+
+- The agreement template carries **two party fields** (`erisaAttestation`, `section4a7Ack`); every
+ buyer submits both values at acceptance, and each condition scans signer values for its own marker,
+ so carrying the §4(a)(7) ack on non-4a7 pathways is harmless.
+- Per-SPV setters (`setRegSConfig`, `setDisclosurePackage`, `setStateBlocked`,
+ `recordGPSignOff`) are gated on the SPV's / DealManager's own BorgAuth via
+ `IBorgAuthProvider(target).AUTH()`; the test corp exposes `AUTH()` for this.
+- Closing conditions are plain (non-proxied) singletons; the threshold conditions are
+ ERC1967-proxied UUPS deployments, matching the intended production topology.
+
+## Not yet covered (future work)
+
+- Negative / revert paths per threshold condition (expired badge, unmet hold, blocked state,
+ holder-cap breach, missing tax form, missing ERISA attestation, U.S. buyer on Reg S, unconfigured
+ Reg S SPV, stale disclosure package, missing GP sign-off).
+- BUY-side offers (bids) per pathway.
+- Partial fills across multiple settlements.
+- `TimeSettlementPeriodCondition` per-DealManager `setDelayOverride` (QMS-mode 45-day parameterization).
+- `GlobalKillCondition` admin rotation (`rotateAdmin`).
diff --git a/specs/analysis/dealManager secondary trades.md b/specs/analysis/dealManager secondary trades.md
new file mode 100644
index 00000000..395f4616
--- /dev/null
+++ b/specs/analysis/dealManager secondary trades.md
@@ -0,0 +1,274 @@
+# DealManager Secondary Trade — Full Lifecycle State Machine
+
+Two parallel state machines run concurrently: the **Offer** (one per `postOffer()`) and one or more **Settlement Escrows
+** (one per `acceptOffer()`).
+
+---
+
+## 1. Offer State Machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> LIVE: postOffer()
+ LIVE --> CANCELLED: cancelOffer()
+ PARTIALLY_ACCEPTED --> CANCELLED: cancelOffer()
+ FULLY_ACCEPTED --> CANCELLED: cancelOffer()
+ LIVE --> PARTIALLY_ACCEPTED: acceptOffer() partial fill
+ LIVE --> FULLY_ACCEPTED: acceptOffer() full fill
+ PARTIALLY_ACCEPTED --> FULLY_ACCEPTED: acceptOffer() completes fill
+ PARTIALLY_ACCEPTED --> LIVE: settlement voided, unitsAccepted back to 0
+ FULLY_ACCEPTED --> LIVE: settlement voided, unitsAccepted back to 0
+ FULLY_ACCEPTED --> PARTIALLY_ACCEPTED: settlement voided, unitsAccepted still gt 0
+ FULLY_ACCEPTED --> FINALIZED: last lot finalized, unitsFinalized == units
+ CANCELLED --> [*]
+ FINALIZED --> [*]
+ note right of LIVE
+ SELL: reserves units on cert at postOffer
+ BUY: pulls full consideration into contract at postOffer
+ end note
+ note right of CANCELLED
+ cancelOffer touches only the free pool:
+ SELL releases uncommitted units (units - unitsAccepted) immediately.
+ BUY: refunds uncommitted consideration (consideration - paymentAccepted) immediately.
+ Settlements already accepted will not cancel and will resolve on their own cadence:
+ finalized normally, or voided via the two-party voidSecondaryTradeAgreement /
+ expiry path. Their assets stay in custody until then.
+ end note
+ note right of FINALIZED
+ Terminal: all offered units consumed by finalized settlements.
+ CANCELLED is sticky — a cancelled offer whose last in-flight lot
+ finalizes stays CANCELLED.
+ Only the terminal states (FINALIZED, CANCELLED) are not cancellable.
+ end note
+ note right of FULLY_ACCEPTED
+ Accepted does not mean settled, parties can still void in-flight settlements.
+ EXPIRED is logical only, no status field change.
+ Enforced at acceptOffer() when block.timestamp > validUntil,
+ and at finalizeDeal() when block.timestamp > secEscrow.expiry.
+ end note
+```
+
+### Offer status transitions
+
+| From | Event | To | Notes |
+|----------------------|-----------------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| *(none)* | `postOffer()` | `LIVE` | SELL: reserves units on cert; BUY: pulls full consideration into contract |
+| any non-terminal | `cancelOffer()` | `CANCELLED` | Releases/refunds only the free pool; accepted settlements will not cancel and will resolve on their own cadence |
+| `LIVE` | `acceptOffer()` — partial fill | `PARTIALLY_ACCEPTED` | `unitsAccepted < units` |
+| `LIVE` | `acceptOffer()` — full fill | `FULLY_ACCEPTED` | `unitsAccepted == units` |
+| `PARTIALLY_ACCEPTED` | `acceptOffer()` — completes fill | `FULLY_ACCEPTED` | |
+| `PARTIALLY_ACCEPTED` | settlement voided | `LIVE` | `unitsAccepted` decrements; if back to 0 and not terminal |
+| `FULLY_ACCEPTED` | settlement voided, `unitsAccepted` drops to 0 | `LIVE` | Same logic as `PARTIALLY_ACCEPTED`: status set purely by `unitsAccepted == 0` check |
+| `FULLY_ACCEPTED` | settlement voided, `unitsAccepted` still > 0 | `PARTIALLY_ACCEPTED` | `unitsAccepted` decrements but offer not empty yet |
+| `FULLY_ACCEPTED` | last settlement finalized | `FINALIZED` | `unitsFinalized == units`; terminal and immutable. CANCELLED stays sticky if the offer was cancelled |
+| any | `block.timestamp > validUntil` | `EXPIRED` (logical) | No status field change; `acceptOffer()` enforces the offer's `validUntil`, while `finalizeDeal()` enforces the settlement's own `secEscrow.expiry` (acceptance + settlement window), which is decoupled from the offer expiry |
+
+---
+
+## 2. Settlement Escrow State Machine (one per `acceptOffer()`)
+
+```mermaid
+stateDiagram-v2
+ [*] --> ACCEPTED: acceptOffer()
+ ACCEPTED --> FINALIZED: finalizeDeal()
+ ACCEPTED --> VOIDED: voidSecondaryTradeAgreement() (both parties)
+ ACCEPTED --> VOIDED: syncVoidedSecondaryTradeAgreement()
+ ACCEPTED --> VOIDED: voidExpiredDeal()
+ note left of ACCEPTED
+ SELL: safeTransferFrom buyer, then escrow written as ACCEPTED.
+ BUY: funds already in contract from postOffer(), escrow written as ACCEPTED directly.
+ end note
+ note right of FINALIZED
+ Pays seller minus fee.
+ Splits fee to integrator and platform.
+ Calls IssuanceManager.secondaryTransfer()
+ to mint buyer cert and void/decrement seller cert,
+ consuming this lot's reserved units as part of the cert mutation.
+ end note
+ note right of VOIDED
+ voidSecondaryTradeAgreement: each party requests; VOIDED only once both have requested
+ (the finalizer-vouched request channel; a lone request only records intent).
+ syncVoidedSecondaryTradeAgreement: anyone, once the registry already shows the agreement voided.
+ voidExpiredDeal: past secEscrow.expiry.
+ Acceptor's asset always returned immediately: SELL refunds the buyer's payment, BUY releases the seller's unit reservation.
+ Offeror's asset stays in custody for the next fill, returned only if the offer has been cancelled:
+ SELL: corresponding units are freed up for the next fill; released if the offer has been cancelled
+ BUY: corresponding funds are freed up for the next fill; refunded if the offer has been cancelled
+ end note
+```
+
+### Settlement escrow status transitions
+
+| From | Event | To | Notes |
+|------------|--------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| *(none)* | `acceptOffer()` — SELL offer | `ACCEPTED` | `safeTransferFrom` buyer pulls payment; escrow written directly as ACCEPTED |
+| *(none)* | `acceptOffer()` — BUY offer | `ACCEPTED` | Funds already in contract from `postOffer()`; escrow written directly as ACCEPTED |
+| `ACCEPTED` | `finalizeDeal()` — conditions met, before expiry | `FINALIZED` | Reverts past `secEscrow.expiry`; pays seller (minus fee), splits fee to integrator/platform, calls `IssuanceManager.secondaryTransfer` to mint buyer cert and void/decrement seller cert, consuming the lot's reserved units |
+| `ACCEPTED` | `voidSecondaryTradeAgreement()` — party requests void | `VOIDED` | Each party (offeror or counterparty) requests; the escrow flips to VOIDED only once both have requested (or it is past expiry). A lone request just records intent and the counterparty can still finalize. |
+| `ACCEPTED` | `syncVoidedSecondaryTradeAgreement()` — registry voided externally | `VOIDED` | Callable by anyone; guards via `isVoided()` check |
+| `ACCEPTED` | `voidExpiredDeal()` — past `secEscrow.expiry` | `VOIDED` | Callable past expiry only |
+
+All `VOIDED` paths share the same asset handling, symmetric between sides. The acceptor's asset is returned
+immediately: SELL refunds the buyer's payment (pulled per settlement at `acceptOffer()`), BUY releases the seller's
+per-settlement unit reservation. The offeror's asset (SELL: reserved units; BUY: consideration) returns to the offer's
+free pool and stays in custody, available for the next fill — it is released/refunded only if the offer is `CANCELLED`,
+since the lot can never be re-accepted.
+
+---
+
+## 3. End-to-End Flow
+
+### 3A. SELL Offer (seller posts, buyer accepts)
+
+```mermaid
+sequenceDiagram
+ actor Seller
+ participant DM as DealManager
+ participant Cert as CertPrinter
+ participant Registry as AgreementRegistry
+ participant IM as IssuanceManager
+ actor Buyer
+ Seller ->> DM: postOffer(SELL, units, consideration)
+ DM ->> Cert: reserveUnits(tokenId, units)
+ Note over DM: Offer: LIVE
+ Buyer ->> DM: acceptOffer(offerId, units, buyer)
+ DM ->> Registry: createContract(templateId, settlementSalt, parties)
+ DM ->> Registry: signContractWithEscrow(offeror, settlementAgreementId)
+ DM ->> Registry: signContractFor(acceptor, settlementAgreementId)
+ DM ->> IM: attachOpenEndorsement(certPrinter, tokenId)
+ Buyer ->> DM: safeTransferFrom(filledConsideration)
+ Note over DM: Settlement: ACCEPTED
+ Note over DM: Offer: PARTIALLY_ACCEPTED or FULLY_ACCEPTED
+ Buyer ->> DM: finalizeDeal(settlementAgreementId)
+ DM ->> Registry: finalizeContract(settlementAgreementId)
+ DM ->> Seller: safeTransfer(paymentToken, toSeller)
+ DM ->> DM: distribute fee to integrator and platform
+ DM ->> IM: secondaryTransfer(dealMetadata)
+ IM ->> Cert: mint new cert to Buyer
+ IM ->> Cert: void or decrement seller cert, consuming the lot's reserved units
+ Note over DM: Settlement: FINALIZED
+```
+
+### 3B. BUY Offer (buyer posts, seller accepts)
+
+```mermaid
+sequenceDiagram
+ actor Buyer
+ participant DM as DealManager
+ participant Cert as CertPrinter
+ participant Registry as AgreementRegistry
+ participant IM as IssuanceManager
+ actor Seller
+ Buyer ->> DM: postOffer(BUY, units, consideration)
+ Buyer ->> DM: safeTransferFrom(consideration)
+ Note over DM: Offer: LIVE, funds in contract custody
+ Seller ->> DM: acceptOffer(offerId, units, sellerTokenId)
+ DM ->> Registry: createContract(templateId, settlementSalt, parties)
+ DM ->> Registry: signContractWithEscrow(offeror, settlementAgreementId)
+ DM ->> Registry: signContractFor(acceptor, settlementAgreementId)
+ DM ->> Cert: reserveUnits(sellerTokenId, units)
+ DM ->> IM: attachOpenEndorsement(certPrinter, sellerTokenId)
+ Note over DM: Settlement: ACCEPTED, no token movement, funds already in contract
+ Note over DM: Offer: PARTIALLY_ACCEPTED or FULLY_ACCEPTED
+ Seller ->> DM: finalizeDeal(settlementAgreementId)
+ DM ->> Registry: finalizeContract(settlementAgreementId)
+ DM ->> Seller: safeTransfer(paymentToken, toSeller)
+ DM ->> DM: distribute fee to integrator and platform
+ DM ->> IM: secondaryTransfer(dealMetadata)
+ IM ->> Cert: mint new cert to Buyer
+ IM ->> Cert: void or decrement seller cert, consuming the lot's reserved units
+ Note over DM: Settlement: FINALIZED
+```
+
+---
+
+## 4. Partial Fill State Sequence (SELL offer, two acceptors)
+
+```mermaid
+sequenceDiagram
+ actor Seller
+ participant DM as DealManager
+ participant Cert as CertPrinter
+ actor Alice
+ actor Bob
+ Seller ->> DM: postOffer(SELL, units=1000, consideration=P)
+ DM ->> Cert: reserveUnits(tokenId, 1000)
+ Note over DM: Offer: LIVE, unitsAccepted=0, paymentAccepted=0
+ Alice ->> DM: acceptOffer(units=400)
+ Alice ->> DM: safeTransferFrom(400 x P/1000)
+ Note over DM: Escrow_A: ACCEPTED, units=400, payment=400P/1000
+ Note over DM: Offer: PARTIALLY_ACCEPTED, unitsAccepted=400
+ Bob ->> DM: acceptOffer(units=600)
+ Bob ->> DM: safeTransferFrom(600 x P/1000)
+ Note over DM: Escrow_B: ACCEPTED, units=600, payment=600P/1000
+ Note over DM: Offer: FULLY_ACCEPTED, unitsAccepted=1000
+ Alice ->> DM: finalizeDeal(settlementAgreementId_A)
+ DM ->> Seller: safeTransfer(400P/1000 - fee)
+ DM ->> IM: secondaryTransfer(dealMetadata_A)
+ IM ->> Cert: mint new cert to Alice (400 units)
+ IM ->> Cert: decrement seller cert by 400 units, consuming 400 reserved units
+ Note over DM: Escrow_A: FINALIZED
+ Note over DM: 600 units still reserved for Escrow_B
+ Bob ->> DM: finalizeDeal(settlementAgreementId_B)
+ DM ->> Seller: safeTransfer(600P/1000 - fee)
+ DM ->> IM: secondaryTransfer(dealMetadata_B)
+ IM ->> Cert: mint new cert to Bob (600 units)
+ IM ->> Cert: void seller cert (0 units remaining), consuming 600 reserved units
+ Note over DM: Escrow_B: FINALIZED, no reserved units remain
+```
+
+---
+
+## 5. Void / Cancellation Paths
+
+```mermaid
+flowchart TD
+ subgraph OFFER_LEVEL["Offer level"]
+ CO["cancelOffer()
by offeror, any non-terminal status"]
+ OC["Offer: CANCELLED"]
+ CO --> OC
+ CO -->|SELL| RU1["releaseUnits(tokenId, units - unitsAccepted)
uncommitted units only"]
+ CO -->|BUY| RF1["refund uncommitted consideration only"]
+ CO --> KEEP["accepted lots stay ACCEPTED
resolve at finalize or two-party/expiry void"]
+ end
+
+ subgraph SETTLEMENT_LEVEL["Settlement level"]
+ VSA["voidSecondaryTradeAgreement()
each party requests; voids once both have"]
+ SVS["syncVoidedSecondaryTradeAgreement()
anyone, registry already voided"]
+ VED["voidExpiredDeal()
past secEscrow.expiry"]
+ SV["Settlement: VOIDED"]
+ VSA --> SV
+ SVS --> SV
+ VED --> SV
+ end
+
+ SV --> ACC["decrement offer.unitsAccepted and offer.paymentAccepted"]
+ ACC -->|SELL, offer CANCELLED| RU2["releaseUnits(tokenId, lot units)"]
+ ACC -->|SELL, offer not CANCELLED| POOL["lot returns to offer's free pool
stays reserved, re-acceptable"]
+ ACC -->|SELL, always| RF2["refund buyer payment"]
+ ACC -->|BUY, always| RU3["releaseUnits(tokenId, lot units)"]
+ ACC -->|BUY, offer CANCELLED| RF3["refund offeror payment"]
+ ACC -->|BUY, offer not CANCELLED| FPOOL["payment returns to offer's free pool
stays in custody, re-acceptable"]
+ ACC --> OS["Restore Offer status"]
+ OS -->|offer is terminal CANCELLED or FINALIZED| OS1["stays terminal"]
+ OS -->|unitsAccepted = = 0| OS2["LIVE"]
+ OS -->|unitsAccepted > 0| OS3["PARTIALLY_ACCEPTED"]
+```
+
+---
+
+## 6. Key Invariants
+
+| Invariant | Where enforced |
+|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `offerId` never registered in `CyberAgreementRegistry` | `postOffer()` makes no registry call |
+| `settlementAgreementId` always fully signed by both parties at creation | `acceptOffer()`: `signContractWithEscrow(offeror)` + `signContractFor(acceptor)` |
+| Buyer's payment enters contract custody before settlement `ACCEPTED` is set | SELL: `safeTransferFrom` then status flip in same tx; BUY: custody at `postOffer()` |
+| An offer's `certPrinter` must be a printer this SPV's IssuanceManager created and tracks | `postOffer()` requires `IssuanceManager.isPrinter(certPrinter)` (authoritative registry check, both sides); `acceptOffer()` reuses `offer.certPrinter`, so a buyer can never pay for a cert minted on a fake/foreign printer |
+| Only the cert's legal owner of record may list its units for sale | SELL: `postOffer()` requires `legalOwnerOf(tokenId) == offeror`; BUY: `acceptOffer()` requires `legalOwnerOf(sellerTokenId) == acceptor` — nothing downstream binds the token to the seller, so this guards against selling (and being paid for) someone else's units |
+| Each settlement's expiry runs from acceptance, not the offer's expiry | `acceptOffer()` stamps both the registry contract deadline and `secEscrow.expiry` with `block.timestamp + settlementWindow` (per-DealManager, default 7d), so a lot accepted just before the offer lapses still gets a full finalize window and cannot be made unfinalizeable by a late acceptance |
+| Seller cert units are reserved before any settlement is created | SELL: `reserveUnits` at `postOffer()`; BUY: `reserveUnits` at `acceptOffer()` per settlement |
+| Each reserved unit is released or consumed exactly once (amount-based reservations, no IDs) | finalize: `secondaryTransfer` consumes the lot; void: BUY releases the lot, SELL releases only if offer `CANCELLED` (else lot returns to free pool); cancel: releases only `units - unitsAccepted` (the free pool) — accepted lots resolve later at finalize or void |
+| Each unit of BUY consideration leaves custody exactly once (payout or refund) | finalize: paid to seller; void: refunded only if offer `CANCELLED` (else returns to free pool); cancel: refunds only `consideration - paymentAccepted` (the free pool) — accepted lots resolve later at finalize or void |
+| Fee always split: integrator portion + platform portion | `_finalizeSecondaryEscrow`: `integratorFee + platformFee == totalFee` |
+| Closing conditions checked at finalize; threshold conditions checked at post/accept AND re-checked at finalize | `finalizeSecondaryTradeAgreement` walks `offer.closingConditions` and also re-runs `_checkThresholdConditions` (both snapshotted from DealManager config at `postOffer`), so eligibility lost after acceptance blocks the asset transfer |
diff --git a/src/CertificateUriBuilder.sol b/src/CertificateUriBuilder.sol
index 2311dece..b781d8af 100644
--- a/src/CertificateUriBuilder.sol
+++ b/src/CertificateUriBuilder.sol
@@ -41,13 +41,18 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity ^0.8.28;
-import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "./CyberCorpConstants.sol";
import "./interfaces/ICyberAgreementRegistry.sol";
import "./interfaces/ICertificateImageBuilder.sol";
+import {RestrictionType, RestrictiveLegend} from "./interfaces/ICyberCertPrinter.sol";
import "./storage/extensions/ICertificateExtension.sol";
import "./libs/auth.sol";
+interface ICertificateUnitsReserved {
+ function unitsReserved(uint256 tokenId) external view returns (uint256);
+}
+
contract CertificateUriBuilder is UUPSUpgradeable, BorgAuthACL {
/// @notice Address of the external image builder contract
@@ -133,6 +138,72 @@ contract CertificateUriBuilder is UUPSUpgradeable, BorgAuthACL {
return string.concat(json, "]");
}
+ function legacyLegendsToRestrictiveLegends(
+ string[] memory arr
+ ) public pure returns (RestrictiveLegend[] memory legends) {
+ legends = new RestrictiveLegend[](arr.length);
+ for (uint256 i = 0; i < arr.length; i++) {
+ legends[i] = RestrictiveLegend({
+ restrictionType: RestrictionType.Custom,
+ title: "",
+ text: arr[i],
+ jurisdiction: "",
+ referenceId: bytes32(0),
+ effectiveTimestamp: 0,
+ expirationTimestamp: 0,
+ active: true,
+ data: ""
+ });
+ }
+ }
+
+ function restrictiveLegendsToJson(RestrictiveLegend[] memory arr) public pure returns (string memory) {
+ string memory json = "[";
+ for (uint256 i = 0; i < arr.length; i++) {
+ if (i > 0) json = string.concat(json, ",");
+ json = string.concat(json, restrictiveLegendToJson(i + 1, arr[i]));
+ }
+ return string.concat(json, "]");
+ }
+
+ function restrictiveLegendToJson(
+ uint256 id,
+ RestrictiveLegend memory legend
+ ) public pure returns (string memory) {
+ string memory part1 = string.concat(
+ '{"id": ', uint256ToString(id),
+ ', "restrictionType": "', restrictionTypeToString(legend.restrictionType),
+ '", "title": "', legend.title,
+ '", "text": "', legend.text,
+ '", "jurisdiction": "', legend.jurisdiction,
+ '"'
+ );
+ string memory part2 = string.concat(
+ ', "referenceId": "0x', bytes32ToString(legend.referenceId),
+ '", "effectiveTimestamp": "', uint256ToString(uint256(legend.effectiveTimestamp)),
+ '", "expirationTimestamp": "', uint256ToString(uint256(legend.expirationTimestamp)),
+ '", "active": "', boolToString(legend.active),
+ '", "data": "', bytesToHexString(legend.data),
+ '"}'
+ );
+ return string.concat(part1, part2);
+ }
+
+ function restrictionTypeToString(RestrictionType restrictionType) public pure returns (string memory) {
+ if (restrictionType == RestrictionType.Unspecified) return "Unspecified";
+ if (restrictionType == RestrictionType.TransferConsentRequired) return "TransferConsentRequired";
+ if (restrictionType == RestrictionType.RestrictedSecurityRule144) return "RestrictedSecurityRule144";
+ if (restrictionType == RestrictionType.UnregisteredSecurities) return "UnregisteredSecurities";
+ if (restrictionType == RestrictionType.RegulationS) return "RegulationS";
+ if (restrictionType == RestrictionType.ContentiousHardfork) return "ContentiousHardfork";
+ if (restrictionType == RestrictionType.Custom) return "Custom";
+ return "Unknown";
+ }
+
+ function boolToString(bool value) public pure returns (string memory) {
+ return value ? "true" : "false";
+ }
+
// Helper function to convert address to string
function addressToString(address _addr) public pure returns (string memory) {
bytes memory s = new bytes(40);
@@ -191,6 +262,11 @@ contract CertificateUriBuilder is UUPSUpgradeable, BorgAuthACL {
return string(abi.encodePacked(wholeStr, ".", centsStr));
}
+ function unitsReservedToString(address contractAddress, uint256 tokenId) internal view returns (string memory) {
+ if (contractAddress == address(0)) return "0.00";
+ return from18DecimalsToString(ICertificateUnitsReserved(contractAddress).unitsReserved(tokenId));
+ }
+
// Helper function to convert bytes32 to string
function bytes32ToString(bytes32 _bytes32) public pure returns (string memory) {
bytes memory bytesArray = new bytes(64);
@@ -257,7 +333,6 @@ struct CertificateDetails {
address ownerAddress;
}
-
/// @notice Fetches the last signed timestamp from the registry for a given agreement
/// @param registry The registry contract address
/// @param agreementId The agreement ID
@@ -442,6 +517,44 @@ struct CertificateDetails {
uint256 tokenId,
address contractAddress,
address extension
+ ) public view returns (string memory) {
+ return buildCertificateUri(
+ cyberCORPName,
+ cyberCORPType,
+ cyberCORPJurisdiction,
+ cyberCORPContactDetails,
+ securityType,
+ securitySeries,
+ certificateUri,
+ legacyLegendsToRestrictiveLegends(certLegend),
+ details,
+ endorsements,
+ owner,
+ registry,
+ agreementId,
+ tokenId,
+ contractAddress,
+ extension
+ );
+ }
+
+ function buildCertificateUri(
+ string memory cyberCORPName,
+ string memory cyberCORPType,
+ string memory cyberCORPJurisdiction,
+ string memory cyberCORPContactDetails,
+ SecurityClass securityType,
+ SecuritySeries securitySeries,
+ string memory certificateUri,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory details,
+ Endorsement[] memory endorsements,
+ OwnerDetails memory owner,
+ address registry,
+ bytes32 agreementId,
+ uint256 tokenId,
+ address contractAddress,
+ address extension
) public view returns (string memory) {
// Start building the JSON string with ERC-721 metadata standard format
// Build on-chain SVG image using the image builder
@@ -494,6 +607,7 @@ struct CertificateDetails {
'", "investmentAmountUSD": "', from18DecimalsToString(details.investmentAmountUSD),
'", "issuerUSDValuationAtTimeOfInvestment": "', from18DecimalsToString(details.issuerUSDValuationAtTimeOfInvestment),
'", "unitsRepresented": "', from18DecimalsToString(details.unitsRepresented),
+ '", "unitsReserved": "', unitsReservedToString(contractAddress, tokenId),
'", "legalDetails": "', details.legalDetails,
'"'
);
@@ -515,7 +629,7 @@ struct CertificateDetails {
);
// Add restrictive legends at the end
- json = string.concat(json, ', "restrictiveLegends": ', arrayToJsonString(certLegend));
+ json = string.concat(json, ', "restrictiveLegends": ', restrictiveLegendsToJson(certLegend));
// Close the main JSON object
json = string.concat(json, '}');
@@ -541,6 +655,44 @@ struct CertificateDetails {
uint256 tokenId,
address contractAddress,
address extension
+ ) public view returns (string memory) {
+ return buildCertificateUriNotEncoded(
+ cyberCORPName,
+ cyberCORPType,
+ cyberCORPJurisdiction,
+ cyberCORPContactDetails,
+ securityType,
+ securitySeries,
+ certificateUri,
+ legacyLegendsToRestrictiveLegends(certLegend),
+ details,
+ endorsements,
+ owner,
+ registry,
+ agreementId,
+ tokenId,
+ contractAddress,
+ extension
+ );
+ }
+
+ function buildCertificateUriNotEncoded(
+ string memory cyberCORPName,
+ string memory cyberCORPType,
+ string memory cyberCORPJurisdiction,
+ string memory cyberCORPContactDetails,
+ SecurityClass securityType,
+ SecuritySeries securitySeries,
+ string memory certificateUri,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory details,
+ Endorsement[] memory endorsements,
+ OwnerDetails memory owner,
+ address registry,
+ bytes32 agreementId,
+ uint256 tokenId,
+ address contractAddress,
+ address extension
) public view returns (string memory) {
// Start building the JSON string with ERC-721 metadata standard format
// Build on-chain SVG image using the image builder
@@ -593,6 +745,7 @@ struct CertificateDetails {
'", "investmentAmountUSD": "', from18DecimalsToString(details.investmentAmountUSD),
'", "issuerUSDValuationAtTimeOfInvestment": "', from18DecimalsToString(details.issuerUSDValuationAtTimeOfInvestment),
'", "unitsRepresented": "', from18DecimalsToString(details.unitsRepresented),
+ '", "unitsReserved": "', unitsReservedToString(contractAddress, tokenId),
'", "legalDetails": "', details.legalDetails,
'"'
);
@@ -614,7 +767,7 @@ struct CertificateDetails {
);
// Add restrictive legends at the end
- json = string.concat(json, ', "restrictiveLegends": ', arrayToJsonString(certLegend));
+ json = string.concat(json, ', "restrictiveLegends": ', restrictiveLegendsToJson(certLegend));
// Close the main JSON object
json = string.concat(json, '}');
@@ -649,7 +802,7 @@ library Base64 {
bytes memory table = TABLE;
- assembly {
+ assembly ("memory-safe") {
let tablePtr := add(table, 1)
let resultPtr := add(result, 32)
diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol
index adf27531..6f1905de 100644
--- a/src/CyberCertPrinter.sol
+++ b/src/CyberCertPrinter.sol
@@ -67,6 +67,12 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
error InvalidEndorsement();
error InvalidLegendIndex();
error SignatureRequired();
+ error LegalOwnerIndexOutOfBounds();
+ // Cert has units reserved (in escrow for a pending deal/loan); its legal ownership is frozen
+ error CertificateReserved();
+ // Reverted from the storage library via delegatecall; declared here for the ABI
+ error ExceedsAvailableUnits();
+ error ExceedsReservedUnits();
//events
event CertificateCreated(uint256 indexed tokenId, address indexed investor, uint256 amount, uint256 cap);
@@ -92,6 +98,8 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
event RestrictionHookSet(uint256 indexed id, address indexed hookAddress);
event GlobalRestrictionHookSet(address indexed hookAddress);
event GlobalTransferableSet(bool indexed transferable);
+ // Emitted from the storage library via delegatecall; declared here for the ABI
+ event UnitsReservedUpdated(uint256 indexed tokenId, uint256 unitsReserved);
modifier onlyIssuanceManager() {
@@ -146,34 +154,33 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
) external onlyIssuanceManager returns (uint256) {
_safeMint(to, tokenId);
- CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId] = CyberCertPrinterStorage.cyberCertStorage().defaultLegend;
- CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details;
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails(
- "",
- to
- );
- emit CyberCertPrinter_CertificateCreated(tokenId);
+ CyberCertPrinterStorage.recordMint(tokenId, to, details);
return tokenId;
}
// Restricted minting with full agreement details
function safeMintAndAssign(
- address to,
+ address to,
uint256 tokenId,
CertificateDetails memory details,
string memory investorName
) external onlyIssuanceManager returns (uint256) {
_safeMint(to, tokenId);
- CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId] = CyberCertPrinterStorage.cyberCertStorage().defaultLegend;
- // Store agreement details
- CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details;
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails(
- investorName,
- to
- );
- string memory issuerName = IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName();
- emit CertificateAssigned(tokenId, to, investorName, issuerName);
- emit CyberCertPrinter_CertificateCreated(tokenId);
+ CyberCertPrinterStorage.recordMintAndAssign(tokenId, to, details, investorName);
+ return tokenId;
+ }
+
+ // Overload: allowing separation of custodian `to` vs legal owner `owner`
+ // This way we can support administered hosting (to != owner) in addition to direct hosting (owner == to)
+ function safeMintAndAssign(
+ address to, // custodian
+ address owner, // legal owner
+ uint256 tokenId,
+ CertificateDetails memory details,
+ string memory ownerName
+ ) external onlyIssuanceManager returns (uint256) {
+ _safeMint(to, tokenId);
+ CyberCertPrinterStorage.recordMintAndAssign(tokenId, owner, details, ownerName);
return tokenId;
}
@@ -184,30 +191,16 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
CertificateDetails memory details
) external onlyIssuanceManager returns (uint256) {
if(ownerOf(tokenId) != from) revert InvalidTokenId();
- CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details;
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails(
- "",
- to
- );
- string memory issuerName = IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName();
- emit CertificateAssigned(tokenId, to, "", issuerName);
+ // Reserved units are escrowed for a pending deal; legal ownership can't be reassigned while on escrow.
+ if (CyberCertPrinterStorage.getUnitsReserved(tokenId) > 0) revert CertificateReserved();
+ CyberCertPrinterStorage.recordAssign(tokenId, to, details);
return tokenId;
}
// Add endorsement (for transfers in secondary market)
function addEndorsement(uint256 tokenId, Endorsement memory newEndorsement) public {
if(msg.sender != CyberCertPrinterStorage.cyberCertStorage().issuanceManager && msg.sender != ownerOf(tokenId)) revert InvalidEndorsement();
- CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].push(newEndorsement);
- emit CertificateEndorsed(
- tokenId,
- newEndorsement.endorser,
- newEndorsement.endorsee,
- newEndorsement.endorseeName,
- newEndorsement.registry,
- newEndorsement.agreementId,
- CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].length - 1,
- block.timestamp
- );
+ CyberCertPrinterStorage.recordEndorsement(tokenId, newEndorsement);
}
function addIssuerSignature(
@@ -227,13 +220,20 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
// Update agreement details
function updateCertificateDetails(uint256 tokenId, CertificateDetails calldata details) external onlyIssuanceManager {
+ // Enforce the reserved-units invariant at the single write chokepoint: raw unitsRepresented may never
+ // drop below the units locked in pending deals. Guards against a caller writing back an effective
+ // (scripified-inflated) or otherwise under-counted balance.
+ if (details.unitsRepresented < CyberCertPrinterStorage.getUnitsReserved(tokenId)) revert ExceedsAvailableUnits();
CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details;
}
// Restricted burning
function burn(uint256 tokenId) external onlyIssuanceManager {
_burn(tokenId);
-
+
+ // No transfer hook fires for a burn (to == 0), so drop the legal-owner enumeration entry explicitly.
+ CyberCertPrinterStorage.recordBurnLegalOwner(tokenId);
+
// Clear agreement details
delete CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId];
delete CyberCertPrinterStorage.cyberCertStorage().issuerSignatures[tokenId];
@@ -248,56 +248,13 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
// Skip restriction checks for minting (from == address(0)) and burning (to == address(0))
if (from != address(0) && to != address(0)) {
- // This is a transfer, check built-in transferability flag and per-token override
- bool globalTransferable = CyberCertPrinterStorage.cyberCertStorage().transferable;
- bool tokenTransferable = CyberCertPrinterStorage.isTokenTransferable(tokenId);
- if (!globalTransferable && !tokenTransferable && from != ICyberCorp(IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).CORP()).dealManager() && from != ICyberCorp(IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).CORP()).roundManager()) revert TokenNotTransferable();
-
- // Check security type-specific hook if it exists
- /* ITransferRestrictionHook typeHook = CyberCertPrinterStorage.cyberCertStorage().restrictionHooksById[tokenId];
-
- if (address(typeHook) != address(0)) {
- (bool allowed, string memory reason) = typeHook.checkTransferRestriction(
- from, to, tokenId, ""
- );
- if (!allowed) revert TransferRestricted(reason);
- }*/
-
- // Check global hook if it exists
- if (address(CyberCertPrinterStorage.cyberCertStorage().globalRestrictionHook) != address(0)) {
- (bool allowed, string memory reason) = CyberCertPrinterStorage.cyberCertStorage().globalRestrictionHook.checkTransferRestriction(
- from, to, tokenId, ""
- );
- if (!allowed) revert TransferRestricted(reason);
- }
-
- address ownerAddress = CyberCertPrinterStorage.cyberCertStorage().owners[tokenId].ownerAddress;
- //check endorsement and update owners
- if(from == ownerAddress) {
- if(!CyberCertPrinterStorage.cyberCertStorage().endorsementRequired) {
- emit CertificateAssigned(tokenId, to, "", IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName());
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails("", to);
- }
- else if(CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].length > 0) {
- Endorsement memory endorsement = CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId][CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].length - 1];
- if (endorsement.endorsee == to) {
- // Endorsement exists; ownership will be updated
- emit CertificateAssigned(tokenId, to, endorsement.endorseeName, IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName());
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails(endorsement.endorseeName, endorsement.endorsee);
- }
- }
- // NOTE: we don't revert in this block: Owner is able to transfer to another address without an endorsement, but it does not update the owner
- }
- else if(CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].length > 0) {
- // Token is not being transferred from the current owner. It can only be transferrred to the latest endorsee, or the current owner
- Endorsement memory endorsement = CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId][CyberCertPrinterStorage.cyberCertStorage().endorsements[tokenId].length - 1];
- if(endorsement.endorsee != to && ownerAddress != to) revert EndorsementNotSignedOrInvalid();
-
- emit CertificateAssigned(tokenId, to, endorsement.endorseeName, IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName());
- CyberCertPrinterStorage.cyberCertStorage().owners[tokenId] = OwnerDetails(endorsement.endorseeName, endorsement.endorsee);
- }
- else revert EndorsementNotSignedOrInvalid();
-
+ // A cert with reserved units is escrowed for a pending deal/loan: its legal ownership is frozen
+ // until the reservation is released at settlement or void. Blocks the transfer vector; assignCert
+ // guards the reassignment vector.
+ if (CyberCertPrinterStorage.getUnitsReserved(tokenId) > 0) revert CertificateReserved();
+ // Restriction + endorsement logic lives in the external library (delegatecall)
+ // to keep this contract under the bytecode size limit
+ CyberCertPrinterStorage.processTransfer(from, to, tokenId);
}
// Emit custom transfer event for indexing
emit CyberCertTransfer(
@@ -305,11 +262,14 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
to,
tokenId
);
+ CyberCertPrinterStorage.recordHolderChange(from, to);
// Call the parent implementation to handle the actual transfer
return super._update(to, tokenId, auth);
}
+ /// @notice `CertificateDetails.unitsRepresented` is re-purposed to `details.unitsRepresented + scripifiedUnits` in this case
+ /// If you need raw `unitsRepresented`, use `getActiveCertificateDetails()` instead
// Get full agreement details
function getCertificateDetails(uint256 tokenId) external view returns (CertificateDetails memory) {
if (ownerOf(tokenId) == address(0)) revert TokenDoesNotExist();
@@ -407,6 +367,10 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
return CyberCertPrinterStorage.cyberCertStorage().defaultLegend;
}
+ function defaultRestrictiveLegends() public view returns (RestrictiveLegend[] memory) {
+ return CyberCertPrinterStorage.cyberCertStorage().defaultLegendsV2;
+ }
+
function certificateUri() public view returns (string memory) {
return CyberCertPrinterStorage.cyberCertStorage().certificateUri;
}
@@ -426,6 +390,10 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
function transferable() public view returns (bool) {
return CyberCertPrinterStorage.cyberCertStorage().transferable;
}
+
+ function holderCount() external view returns (uint256) {
+ return CyberCertPrinterStorage.getHolderCount();
+ }
function _exists(uint256 tokenId) internal view virtual returns (bool) {
return _ownerOf(tokenId) != address(0);
@@ -436,21 +404,11 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
}
function addDefaultLegend(string memory newLegend) external onlyIssuanceManager {
- CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
- s.defaultLegend.push(newLegend);
+ CyberCertPrinterStorage.addLegend(0, true, newLegend);
}
function removeDefaultLegendAt(uint256 index) external onlyIssuanceManager {
- CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
- if (index >= s.defaultLegend.length) revert InvalidLegendIndex();
-
- // Move the last element to the index being removed (if it's not the last element)
- // and then pop the last element
- uint256 lastIndex = s.defaultLegend.length - 1;
- if (index != lastIndex) {
- s.defaultLegend[index] = s.defaultLegend[lastIndex];
- }
- s.defaultLegend.pop();
+ CyberCertPrinterStorage.removeLegendAt(0, true, index);
}
function getDefaultLegendAt(uint256 index) external view returns (string memory) {
@@ -464,22 +422,31 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
return CyberCertPrinterStorage.cyberCertStorage().defaultLegend.length;
}
- function addCertLegend(uint256 tokenId, string memory newLegend) external onlyIssuanceManager {
- CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
- s.certLegend[tokenId].push(newLegend);
+ function addDefaultRestrictiveLegend(RestrictiveLegend memory newLegend) external onlyIssuanceManager {
+ CyberCertPrinterStorage.addRestrictiveLegend(0, true, newLegend);
}
- function removeCertLegendAt(uint256 tokenId, uint256 index) external onlyIssuanceManager {
+ function removeDefaultRestrictiveLegendAt(uint256 index) external onlyIssuanceManager {
+ CyberCertPrinterStorage.removeRestrictiveLegendAt(0, true, index);
+ }
+
+ function getDefaultRestrictiveLegendAt(uint256 index) external view returns (RestrictiveLegend memory) {
CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
- if (index >= s.certLegend[tokenId].length) revert InvalidLegendIndex();
+ if (index >= s.defaultLegendsV2.length) revert InvalidLegendIndex();
- // Move the last element to the index being removed (if it's not the last element)
- // and then pop the last element
- uint256 lastIndex = s.certLegend[tokenId].length - 1;
- if (index != lastIndex) {
- s.certLegend[tokenId][index] = s.certLegend[tokenId][lastIndex];
- }
- s.certLegend[tokenId].pop();
+ return s.defaultLegendsV2[index];
+ }
+
+ function getDefaultRestrictiveLegendCount() external view returns (uint256) {
+ return CyberCertPrinterStorage.cyberCertStorage().defaultLegendsV2.length;
+ }
+
+ function addCertLegend(uint256 tokenId, string memory newLegend) external onlyIssuanceManager {
+ CyberCertPrinterStorage.addLegend(tokenId, false, newLegend);
+ }
+
+ function removeCertLegendAt(uint256 tokenId, uint256 index) external onlyIssuanceManager {
+ CyberCertPrinterStorage.removeLegendAt(tokenId, false, index);
}
function getCertLegendAt(uint256 tokenId, uint256 index) external view returns (string memory) {
@@ -493,6 +460,25 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
return CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId].length;
}
+ function addCertRestrictiveLegend(uint256 tokenId, RestrictiveLegend memory newLegend) external onlyIssuanceManager {
+ CyberCertPrinterStorage.addRestrictiveLegend(tokenId, false, newLegend);
+ }
+
+ function removeCertRestrictiveLegendAt(uint256 tokenId, uint256 index) external onlyIssuanceManager {
+ CyberCertPrinterStorage.removeRestrictiveLegendAt(tokenId, false, index);
+ }
+
+ function getCertRestrictiveLegendAt(uint256 tokenId, uint256 index) external view returns (RestrictiveLegend memory) {
+ CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
+ if (index >= s.certLegendsV2[tokenId].length) revert InvalidLegendIndex();
+
+ return s.certLegendsV2[tokenId][index];
+ }
+
+ function getCertRestrictiveLegendCount(uint256 tokenId) external view returns (uint256) {
+ return CyberCertPrinterStorage.cyberCertStorage().certLegendsV2[tokenId].length;
+ }
+
function getExtension(uint256 tokenId) external view returns (address) {
return CyberCertPrinterStorage.cyberCertStorage().extension;
}
@@ -509,6 +495,22 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
CyberCertPrinterStorage.cyberCertStorage().tokenTransferable[tokenId] = value;
}
+ /// @notice Reserve units of a certificate against a pending deal/loan; cannot exceed the cert's units
+ function increaseUnitsReserved(uint256 tokenId, uint256 amount) external onlyIssuanceManager {
+ if (!_exists(tokenId)) revert TokenDoesNotExist();
+ CyberCertPrinterStorage.increaseUnitsReserved(tokenId, amount);
+ }
+
+ /// @notice Release previously reserved units; cannot release more than is reserved
+ function decreaseUnitsReserved(uint256 tokenId, uint256 amount) external onlyIssuanceManager {
+ if (!_exists(tokenId)) revert TokenDoesNotExist();
+ CyberCertPrinterStorage.decreaseUnitsReserved(tokenId, amount);
+ }
+
+ function unitsReserved(uint256 tokenId) public view returns (uint256) {
+ return CyberCertPrinterStorage.getUnitsReserved(tokenId);
+ }
+
function isTokenTransferable(uint256 tokenId) external view returns (bool) {
return CyberCertPrinterStorage.cyberCertStorage().tokenTransferable[tokenId];
}
@@ -518,4 +520,25 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable {
return CyberCertPrinterStorage.cyberCertStorage().owners[tokenId].ownerAddress;
}
+ /// @notice Number of certificates `owner` is the legal owner of record for (distinct from ERC-721 custody).
+ function balanceOfLegalOwner(address owner) external view returns (uint256) {
+ return CyberCertPrinterStorage.cyberCertStorage().legalOwnerTokenCount[owner];
+ }
+
+ /// @notice The `index`-th certificate `owner` is the legal owner of record for. Enumerates by legal owner,
+ /// so it lists a holder's certs even when a custodian (e.g. an admin multisig) holds the NFTs.
+ function tokenOfLegalOwnerByIndex(address owner, uint256 index) external view returns (uint256) {
+ CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
+ if (index >= s.legalOwnerTokenCount[owner]) revert LegalOwnerIndexOutOfBounds();
+ return s.legalOwnedTokens[owner][index];
+ }
+
+ /// @notice Backfill the legal-owner enumeration for tokens in [startIndex, startIndex+count) of the supply.
+ /// For printers deployed before the enumeration existed: permissionless and idempotent (already-tracked
+ /// tokens are skipped), call in batches over [0, totalSupply()) after a beacon upgrade. New printers need it
+ /// only if you want to (harmlessly) re-run it.
+ function backfillLegalOwners(uint256 startIndex, uint256 count) external {
+ CyberCertPrinterStorage.backfillLegalOwnerEnumeration(startIndex, count);
+ }
+
}
diff --git a/src/CyberCorp.sol b/src/CyberCorp.sol
index 31813fa4..2b516ee1 100644
--- a/src/CyberCorp.sol
+++ b/src/CyberCorp.sol
@@ -45,6 +45,7 @@ import "./libs/auth.sol";
import "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
import "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "./interfaces/ICyberCorpSingleFactory.sol";
+import "./storage/extensions/ICyberCorpExtension.sol";
/// @title CyberCorp
/// @notice Main contract representing a corporation's on-chain presence and management
@@ -80,6 +81,12 @@ contract CyberCorp is Initializable, BorgAuthACL, UUPSUpgradeable {
address public roundManager;
/// @notice Escrowed officer signatures that can be applied to certificates
bytes[] public escrowedOfficerSignatures;
+ /// @notice Extension contract that interprets `extensionData`
+ address public extension;
+ /// @notice Type selector for the active extension schema
+ bytes32 public extensionType;
+ /// @notice Raw extension payload interpreted by the active extension contract
+ bytes public extensionData;
event CyberCORPDetailsUpdated(string cyberCORPName, string cyberCORPType, string cyberCORPJurisdiction, string cyberCORPContactDetails, string defaultDisputeResolution);
event OfficerAdded(address indexed officer, uint256 index);
@@ -87,10 +94,15 @@ contract CyberCorp is Initializable, BorgAuthACL, UUPSUpgradeable {
event CompanyPayableUpdated(address indexed companyPayable, address indexed oldCompanyPayable);
event EscrowedOfficerSignatureAdded(uint256 indexed index, address indexed officer);
event EscrowedOfficerSignatureUpdated(uint256 indexed index, address indexed officer);
+ event CyberCORPExtensionSet(address indexed extension, bytes32 indexed extensionType);
+ event CyberCORPExtensionDataUpdated(bytes32 indexed extensionType, bytes extensionData);
error NotRefImplementation();
error SignatureRequired();
error InvalidEscrowSignatureIndex();
+ error InvalidExtension();
+ error ExtensionTypeNotSupported();
+ error ExtensionNotConfigured();
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
@@ -263,6 +275,56 @@ contract CyberCorp is Initializable, BorgAuthACL, UUPSUpgradeable {
return escrowedOfficerSignatures.length;
}
+ /// @notice Set or replace the active CyberCorp extension contract and schema type
+ /// @dev Setting a new extension clears any previously stored extension data
+ function setExtension(
+ address _extension,
+ bytes32 _extensionType
+ ) external onlyOwner {
+ if (_extension == address(0)) {
+ if (_extensionType != bytes32(0)) revert InvalidExtension();
+ extension = address(0);
+ extensionType = bytes32(0);
+ delete extensionData;
+ emit CyberCORPExtensionSet(address(0), bytes32(0));
+ emit CyberCORPExtensionDataUpdated(bytes32(0), "");
+ return;
+ }
+
+ if (
+ !ICyberCorpExtension(_extension).supportsExtensionType(_extensionType)
+ ) revert ExtensionTypeNotSupported();
+
+ extension = _extension;
+ extensionType = _extensionType;
+ delete extensionData;
+
+ emit CyberCORPExtensionSet(_extension, _extensionType);
+ emit CyberCORPExtensionDataUpdated(_extensionType, "");
+ }
+
+ /// @notice Update the raw extension payload for the active CyberCorp extension
+ function setExtensionData(bytes calldata _extensionData) external onlyOwner {
+ if (extension == address(0)) revert ExtensionNotConfigured();
+ extensionData = _extensionData;
+ emit CyberCORPExtensionDataUpdated(extensionType, _extensionData);
+ }
+
+ /// @notice Clear the active CyberCorp extension and any stored extension data
+ function clearExtension() external onlyOwner {
+ extension = address(0);
+ extensionType = bytes32(0);
+ delete extensionData;
+ emit CyberCORPExtensionSet(address(0), bytes32(0));
+ emit CyberCORPExtensionDataUpdated(bytes32(0), "");
+ }
+
+ /// @notice Returns the extension-provided JSON fragment for the current extension payload
+ function getExtensionURI() external view returns (string memory) {
+ if (extension == address(0) || extensionData.length == 0) return "";
+ return ICyberCorpExtension(extension).getExtensionURI(extensionData);
+ }
+
// ========================
// UUPSUpgradeable
// ========================
diff --git a/src/DealManager.sol b/src/DealManager.sol
index 6af09b84..f7b8f196 100644
--- a/src/DealManager.sol
+++ b/src/DealManager.sol
@@ -41,81 +41,55 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
-import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
-import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
-import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+import "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
+import "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "openzeppelin-contracts/utils/ReentrancyGuard.sol";
+import "openzeppelin-contracts/token/ERC20/IERC20.sol";
+import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
import "./interfaces/IIssuanceManager.sol";
-import "./libs/LexScroWLite.sol";
import "./libs/auth.sol";
import "./storage/DealManagerStorage.sol";
import "./storage/DealManagerFactoryStorage.sol";
import "./storage/BorgAuthStorage.sol";
+import "./storage/SecondaryTradeStorage.sol";
import "./interfaces/ICyberCorp.sol";
+import "./interfaces/IDealManager.sol";
+import "./interfaces/IDealManagerStorage.sol";
+import "./interfaces/ISecondaryTradeStorage.sol";
+import "./interfaces/ILexScrowStorage.sol";
import "./interfaces/IDealManagerFactory.sol";
+import "./interfaces/ICyberCertPrinter.sol";
+import "./interfaces/ICyberAgreementRegistry.sol";
/// @title DealManager
/// @notice Manages the lifecycle of deals between parties, including creation, signing, payment, and finalization for a CyberCorp
/// @dev Implements UUPS upgradeable pattern and integrates with BorgAuth for access control
-contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeable, ReentrancyGuard {
+contract DealManager is
+ Initializable,
+ BorgAuthACL,
+ UUPSUpgradeable,
+ ReentrancyGuard,
+ IDealManager,
+ IDealManagerStorage,
+ ISecondaryTradeStorage,
+ ILexScrowStorage
+{
using DealManagerStorage for DealManagerStorage.DealManagerData;
+ using SafeERC20 for IERC20;
- string public constant DEPLOY_VERSION = "4"; // For version-tracking on all deployment and future upgrades
+ string public constant DEPLOY_VERSION = "4.0.1"; // For version-tracking on all deployment and future upgrades
- /// @notice Certificate data structure for creating new certificates
- struct CyberCertData {
- string name;
- string symbol;
- string uri;
- SecurityClass securityClass;
- SecuritySeries securitySeries;
- address extension;
- string[] defaultLegend;
- }
+ // Library-emitted events/errors are inherited from the per-library interfaces (IDealManagerStorage,
+ // ISecondaryTradeStorage and ILexScrowStorage) so their selectors/topics appear in DealManager's ABI.
+ // The errors/event below are owned and used directly by DealManager. (Shared
error ZeroAddress();
- error CounterPartyValueMismatch();
- error AgreementConditionsNotMet();
- error DealNotPending();
error PartyValuesLengthMismatch();
error ConditionAlreadyExists();
error ConditionDoesNotExist();
- error NotUpgradeFactory();
- error DealNotExpired();
error NotRefImplementation();
-
- /// @notice Emitted when a new deal is proposed
- /// @param agreementId Unique identifier for the agreement
- /// @param certAddress Address of the certificate contract
- /// @param certId ID of the certificate
- /// @param paymentToken Address of the token used for payment
- /// @param paymentAmount Amount to be paid
- /// @param templateId ID of the template used for the agreement
- /// @param corp Address of the CyberCorp
- /// @param dealRegistry Address of the CyberAgreementRegistry
- /// @param parties Array of party addresses involved in the deal
- /// @param conditions Array of condition contract addresses
- /// @param hasSecret Whether the deal requires a secret for finalization
- event DealProposed(
- bytes32 indexed agreementId,
- address[] certAddress,
- uint256[] certId,
- address paymentToken,
- uint256 paymentAmount,
- bytes32 templateId,
- address corp,
- address dealRegistry,
- address[] parties,
- address[] conditions,
- bool hasSecret
- );
-
- event DealFinalized(
- bytes32 indexed agreementId,
- address indexed signer,
- address indexed corp,
- address dealRegistry,
- bool fillUnallocated
- );
+ event MinTradeThresholdSet(uint256 minUnits, uint256 minConsideration, address setter);
+ event SettlementWindowSet(uint256 window, address setter);
/// @notice Maps agreement IDs to arrays of counter party values for closed deals.
mapping(bytes32 => string[]) public counterPartyValues;
@@ -141,8 +115,9 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
// Set storage values
DealManagerStorage.setIssuanceManager(_issuanceManager);
- // Initialize LexScroWLite core addresses
- __LexScroWLite_init(_corp, _dealRegistry);
+ // Initialize LexScrowStorage core addresses (LexScrowStorage is now a library — set storage directly)
+ LexScrowStorage.setCorp(_corp);
+ LexScrowStorage.setDealRegistry(_dealRegistry);
DealManagerStorage.setUpgradeFactory(_upgradeFactory);
}
@@ -176,47 +151,10 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
bytes32 secretHash,
uint256 expiry
) public onlyOwner returns (bytes32 agreementId, uint256[] memory certIds) {
- agreementId = ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).createContract(_templateId, _salt, _globalValues, _parties, _partyValues, secretHash, address(this), expiry);
-
- Token[] memory corpAssets = new Token[](_certDetails.length);
- certIds = new uint256[](_certDetails.length);
- for(uint256 i = 0; i < _certDetails.length; i++) {
- certIds[i] = DealManagerStorage.getIssuanceManager().createCert(_certPrinterAddress[i], address(this), _certDetails[i]);
- corpAssets[i] = Token(TokenType.ERC721, _certPrinterAddress[i], certIds[i], 1, false);
- }
-
- Token[] memory buyerAssets = new Token[](1);
- buyerAssets[0] = Token(TokenType.ERC20, _paymentToken, 0, _paymentAmount, true); // Will be used as fee token
-
- Escrow memory newEscrow = Escrow({
- agreementId: agreementId,
- counterParty: _parties[1],
- corpAssets: corpAssets,
- buyerAssets: buyerAssets,
- signature: "",
- expiry: expiry,
- status: EscrowStatus.PENDING
- });
-
- LexScrowStorage.setEscrow(agreementId, newEscrow);
-
- //set conditions
- for(uint256 i = 0; i < conditions.length; i++) {
- LexScrowStorage.addConditionToEscrow(agreementId, ICondition(conditions[i]));
- }
-
- emit DealProposed(
- agreementId,
- _certPrinterAddress,
- certIds,
- _paymentToken,
- _paymentAmount,
- _templateId,
- LexScrowStorage.getCorp(),
- LexScrowStorage.getDealRegistry(),
- _parties,
- conditions,
- secretHash > 0
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ return DealManagerStorage.proposeDeal(
+ _certPrinterAddress, _paymentToken, _paymentAmount, _templateId, _salt,
+ _globalValues, _parties, _certDetails, _partyValues, conditions, secretHash, expiry
);
}
@@ -253,14 +191,19 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
address[] memory conditions,
bytes32 secretHash,
uint256 expiry
- ) public returns (bytes32 agreementId, uint256[] memory certIds) {
+ ) public onlyOwner returns (bytes32 agreementId, uint256[] memory certIds) {
+ // Implemented here (not in DealManagerStorage) on purpose: keeping proposeAndSignDeal out of that
+ // library stops the via-ir Yul optimizer from inlining proposeDeal into it (which overflows the
+ // stack). proposeDeal is reached via a cross-contract delegatecall, so its heavy body stays in the
+ // linked library and is never inlined here.
if(_partyValues.length > _parties.length) revert PartyValuesLengthMismatch();
-
- certIds = new uint256[](_certDetails.length);
- (agreementId, certIds) = proposeDeal(_certPrinterAddress, _paymentToken, _paymentAmount, _templateId, _salt, _globalValues, _parties, _certDetails, _partyValues, conditions, secretHash, expiry);
+ (agreementId, certIds) = DealManagerStorage.proposeDeal(
+ _certPrinterAddress, _paymentToken, _paymentAmount, _templateId, _salt,
+ _globalValues, _parties, _certDetails, _partyValues, conditions, secretHash, expiry
+ );
// NOTE: proposer is expected to be listed as a party in the parties array.
-
+
// Update the escrow signature
Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
escrow.signature = signature;
@@ -290,23 +233,8 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
string memory name,
string memory secret
) public {
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId)) revert DealVoided();
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isFinalized(agreementId)) revert DealAlreadyFinalized();
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
- if(escrow.status != EscrowStatus.PENDING) revert DealNotPending();
- if(escrow.expiry < block.timestamp) revert DealExpired();
-
- string[] storage counterPartyCheck = DealManagerStorage.getCounterPartyValues(agreementId);
- if(counterPartyCheck.length > 0) {
- if (keccak256(abi.encode(counterPartyCheck)) != keccak256(abi.encode(partyValues))) revert CounterPartyValueMismatch();
- }
- else {
- DealManagerStorage.setCounterPartyValues(agreementId, partyValues);
- }
-
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).signContractFor(signer, agreementId, partyValues, signature, _fillUnallocated, secret);
- updateEscrow(agreementId, signer, name);
- handleCounterPartyPayment(agreementId);
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.signDealAndPay(signer, agreementId, signature, partyValues, _fillUnallocated, name, secret);
}
/// @notice Signs and finalizes a deal in one transaction
@@ -326,56 +254,19 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
bool _fillUnallocated,
string memory name,
string memory secret
- ) public {
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId)) revert DealVoided();
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isFinalized(agreementId)) revert DealAlreadyFinalized();
- if(LexScrowStorage.getEscrow(agreementId).status != EscrowStatus.PENDING) revert DealNotPending();
-
- string[] storage counterPartyCheck = DealManagerStorage.getCounterPartyValues(agreementId);
- if(counterPartyCheck.length > 0) {
- if (keccak256(abi.encode(counterPartyCheck)) != keccak256(abi.encode(partyValues))) revert CounterPartyValueMismatch();
- } else {
- DealManagerStorage.setCounterPartyValues(agreementId, partyValues);
- }
-
- if (!ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).hasSigned(agreementId, signer)) {
- // Not signed in registry yet; enforce local consistency and then sign
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).signContractFor(signer, agreementId, partyValues, signature, _fillUnallocated, secret);
- } else {
- // Already signed in registry; fetch values recorded in the registry and ensure consistency
- string[] memory registryValues = ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).getSignerValues(agreementId, signer);
- if (keccak256(abi.encode(registryValues)) != keccak256(abi.encode(partyValues))) revert CounterPartyValueMismatch();
- }
-
- updateEscrow(agreementId, signer, name);
- if(!conditionCheck(agreementId)) revert AgreementConditionsNotMet();
- handleCounterPartyPayment(agreementId);
- finalizeDeal(agreementId);
+ ) public nonReentrant {
+ // Thin wrapper over the linked DealManagerStorage logic. nonReentrant is required here because the
+ // moved logic invokes finalizeDeal as an internal library call that no longer passes through the
+ // guarded finalizeDeal wrapper below.
+ DealManagerStorage.signAndFinalizeDeal(signer, agreementId, partyValues, signature, _fillUnallocated, name, secret);
}
/// @notice Finalizes a deal
/// @dev Checks signatures, conditions and finalizes the agreement
/// @param agreementId Unique identifier for the agreement
function finalizeDeal(bytes32 agreementId) public nonReentrant {
- // Check: status
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId)) revert DealVoided();
- if(LexScrowStorage.getEscrow(agreementId).status != EscrowStatus.PAID) revert DealNotPaid();
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isFinalized(agreementId)) revert DealAlreadyFinalized();
- if(!ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).allPartiesSigned(agreementId)) revert DealNotFullySigned();
- if(!conditionCheck(agreementId)) revert AgreementConditionsNotMet();
-
- // Effect: update status
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).finalizeContract(agreementId);
-
- // Interaction: payments
- finalizeEscrow(agreementId);
- emit DealFinalized(
- agreementId,
- msg.sender,
- LexScrowStorage.getCorp(),
- LexScrowStorage.getDealRegistry(),
- false
- );
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.finalizeDeal(agreementId);
}
/// @notice Voids an expired deal
@@ -384,27 +275,8 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// @param signer Address of the signer
/// @param signature Signature of the signer
function voidExpiredDeal(bytes32 agreementId, address signer, bytes memory signature) public nonReentrant {
- // Check: status
- Escrow storage deal = LexScrowStorage.getEscrow(agreementId);
- if (block.timestamp <= deal.expiry) revert DealNotExpired();
-
- // Effect: update status
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
- for(uint256 i = 0; i < deal.corpAssets.length; i++) {
- if(deal.corpAssets[i].tokenType == TokenType.ERC721) {
- DealManagerStorage.getIssuanceManager().voidCertificate(
- deal.corpAssets[i].tokenAddress,
- deal.corpAssets[i].tokenId
- );
- }
- }
-
- if(deal.status == EscrowStatus.PAID)
- // Interaction: payment
- voidAndRefund(agreementId);
- else if(deal.status == EscrowStatus.PENDING)
- // Effect: update status
- voidEscrow(agreementId);
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.voidExpiredDeal(agreementId, signer, signature);
}
/// @notice Revokes a pending deal
@@ -413,11 +285,8 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// @param signer Address of the signer
/// @param signature Signature of the signer
function revokeDeal(bytes32 agreementId, address signer, bytes memory signature) public {
- if(msg.sender != signer) revert CounterPartyValueMismatch();
- if(LexScrowStorage.getEscrow(agreementId).status == EscrowStatus.PENDING)
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
- else
- revert DealNotPending();
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.revokeDeal(agreementId, signer, signature);
}
/// @notice Signs to void a deal
@@ -426,14 +295,8 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// @param signer Address of the signer
/// @param signature Signature of the signer
function signToVoid(bytes32 agreementId, address signer, bytes memory signature) public nonReentrant {
- // Check: status
- if(msg.sender != signer) revert CounterPartyValueMismatch();
-
- // Effect: update status
- ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
- if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId) && LexScrowStorage.getEscrow(agreementId).status == EscrowStatus.PAID)
- // Interaction: payment
- voidAndRefund(agreementId);
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.signToVoid(agreementId, signer, signature);
}
/// @notice Refund a voided deal
@@ -441,8 +304,8 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// (e.g. directly to CyberAgreementRegistry without being processed by Deal Manager)
/// @param agreementId Unique identifier for the agreement
function refundVoidedDeal(bytes32 agreementId) public nonReentrant {
- // Interaction: Re-sync Deal Manager internal escrow to VOIDED, then refund
- voidAndRefund(agreementId);
+ // Thin wrapper over the linked DealManagerStorage logic (delegatecall keeps storage/msg.sender)
+ DealManagerStorage.refundVoidedDeal(agreementId);
}
/// @notice Adds a condition to a deal
@@ -529,7 +392,7 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// @return certIds Array of certificate IDs created
function proposeAndSignNewCertsDeal(
uint256 salt,
- CyberCertData[] memory _certData,
+ DealManagerStorage.CyberCertData[] memory _certData,
bytes32 _templateId,
string[] memory _globalValues,
address[] memory _parties,
@@ -546,13 +409,16 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
bytes32 id,
uint256[] memory certIds
) {
- // Get company name from the parent CyberCorp
- string memory companyName = ICyberCorp(LexScrowStorage.getCorp()).cyberCORPName();
-
+ // Lives here alongside proposeAndSignDeal (its only internal caller) so that function can stay out of
+ // DealManagerStorage — see the note on proposeAndSignDeal.
certPrinterAddress = new address[](_certData.length);
- for (uint256 i = 0; i < _certData.length; i++) {
- ICyberCertPrinter certPrinter = ICyberCertPrinter(
- DealManagerStorage.getIssuanceManager().createCertPrinter(
+ // Scope companyName + loop temporaries so they (and _certData) are freed before the heavy
+ // proposeAndSignDeal call below — keeps via-ir stack scheduling within budget.
+ {
+ // Get company name from the parent CyberCorp
+ string memory companyName = ICyberCorp(LexScrowStorage.getCorp()).cyberCORPName();
+ for (uint256 i = 0; i < _certData.length; i++) {
+ certPrinterAddress[i] = DealManagerStorage.getIssuanceManager().createCertPrinter(
_certData[i].defaultLegend,
string.concat(companyName, " ", _certData[i].name),
_certData[i].symbol,
@@ -560,13 +426,12 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
_certData[i].securityClass,
_certData[i].securitySeries,
_certData[i].extension
- )
- );
- certPrinterAddress[i] = address(certPrinter);
+ );
+ }
}
// Create and sign deal
- certIds = new uint256[](_certData.length);
+ certIds = new uint256[](certPrinterAddress.length);
(id, certIds) = proposeAndSignDeal(
certPrinterAddress,
stableAddress,
@@ -589,17 +454,208 @@ contract DealManager is Initializable, BorgAuthACL, LexScroWLite, UUPSUpgradeabl
/// @dev Currently the factory owner (MetaLeX) unilaterally set the fee ratio;
/// in the future, it could be determined through a governance process.
/// @return Fee amount
- function computeFee(uint256 size) public override view returns (uint256) {
+ function computeFee(uint256 size) public view returns (uint256) {
return size * IDealManagerFactory(DealManagerStorage.getUpgradeFactory()).getDefaultFeeRatio() / DealManagerFactoryStorage.BASIS_POINTS;
}
/// @notice Gets the payable address for the fees
/// @dev The factory owner (MetaLeX) unilaterally set the payable address
/// @return Payable address for the fees
- function getPlatformPayable() public override view returns (address) {
+ function getPlatformPayable() public view returns (address) {
return IDealManagerFactory(DealManagerStorage.getUpgradeFactory()).getPlatformPayable();
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // LexScrowStorage surface — thin wrappers (LexScrowStorage is now a library) so the proxy keeps
+ // exposing these selectors for off-chain callers and condition contracts (ILexScrowStorage).
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Get escrow details for a given agreement id
+ function getEscrowDetails(bytes32 agreementId) public view returns (Escrow memory) {
+ return LexScrowStorage.getEscrow(agreementId);
+ }
+
+ /// @notice Check all conditions attached to the escrow for the given agreement
+ function conditionCheck(bytes32 agreementId) public view returns (bool) {
+ return LexScrowStorage.conditionCheck(agreementId);
+ }
+
+ /// @notice ERC721 receiver hook for safe transfers into escrow (moved here from LexScrowStorage)
+ function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC721Received.selector;
+ }
+
+ /// @notice ERC1155 receiver hook for safe transfers into escrow (moved here from LexScrowStorage)
+ function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC1155Received.selector;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Secondary trade — threshold setters
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function setMinTradeThreshold(uint256 units, uint256 consideration) external onlyAdmin {
+ SecondaryTradeStorage.SecondaryTradeData storage ds = SecondaryTradeStorage.secondaryTradeStorage();
+ ds.minTradeUnits = units;
+ ds.minTradeConsideration = consideration;
+ emit MinTradeThresholdSet(units, consideration, msg.sender);
+ }
+
+ function setSettlementWindow(uint256 window) external onlyAdmin {
+ SecondaryTradeStorage.secondaryTradeStorage().settlementWindow = window;
+ emit SettlementWindowSet(window, msg.sender);
+ }
+
+ function setDefaultIntegrator(address integrator) external onlyAdmin {
+ if (integrator != address(0)) {
+ if (!IDealManagerFactory(DealManagerStorage.getUpgradeFactory()).isIntegratorWhitelisted(integrator))
+ revert IntegratorNotWhitelisted();
+ }
+ SecondaryTradeStorage.secondaryTradeStorage().defaultIntegrator = integrator;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Secondary trade — condition config (owner-managed; snapshotted onto each offer at postOffer)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Layer 2 — fund-specific (§6) threshold conditions (apply to every offer)
+ function addSpvThresholdCondition(address condition) external onlyAdmin {
+ SecondaryTradeStorage.addSpvThresholdCondition(condition);
+ }
+
+ function removeSpvThresholdConditionAt(uint256 index) external onlyAdmin {
+ SecondaryTradeStorage.removeSpvThresholdConditionAt(index);
+ }
+
+ // Layer 1 — exemption-specific (§5) threshold conditions (selected by offer.exemptionPathway)
+ function addPathwayThresholdCondition(ExemptionPathway pathway, address condition) external onlyAdmin {
+ SecondaryTradeStorage.addPathwayThresholdCondition(pathway, condition);
+ }
+
+ function removePathwayThresholdConditionAt(ExemptionPathway pathway, uint256 index) external onlyAdmin {
+ SecondaryTradeStorage.removePathwayThresholdConditionAt(pathway, index);
+ }
+
+ // Closing conditions (apply to every offer; evaluated at finalize)
+ function addClosingCondition(address condition) external onlyAdmin {
+ SecondaryTradeStorage.addClosingCondition(condition);
+ }
+
+ function removeClosingConditionAt(uint256 index) external onlyAdmin {
+ SecondaryTradeStorage.removeClosingConditionAt(index);
+ }
+
+ function getSpvThresholdConditions() external view returns (address[] memory) {
+ return SecondaryTradeStorage.secondaryTradeStorage().spvThresholdConditions;
+ }
+
+ function getPathwayThresholdConditions(ExemptionPathway pathway) external view returns (address[] memory) {
+ return SecondaryTradeStorage.secondaryTradeStorage().pathwayThresholdConditions[pathway];
+ }
+
+ function getClosingConditions() external view returns (address[] memory) {
+ return SecondaryTradeStorage.secondaryTradeStorage().closingConditions;
+ }
+
+ function getMinTradeThreshold() external view returns (uint256 units, uint256 consideration) {
+ SecondaryTradeStorage.SecondaryTradeData storage ds = SecondaryTradeStorage.secondaryTradeStorage();
+ return (ds.minTradeUnits, ds.minTradeConsideration);
+ }
+
+ function getDefaultIntegrator() external view returns (address) {
+ return SecondaryTradeStorage.secondaryTradeStorage().defaultIntegrator;
+ }
+
+ /// @notice Effective settlement window (applies the default when unset).
+ function getSettlementWindow() external view returns (uint256) {
+ return SecondaryTradeStorage.getSettlementWindow();
+ }
+
+ function getOffer(bytes32 offerId) external view returns (Offer memory) {
+ return SecondaryTradeStorage.secondaryTradeStorage().offers[offerId];
+ }
+
+ function getSecondaryEscrow(bytes32 agreementId) external view returns (SecondaryEscrow memory) {
+ return SecondaryTradeStorage.secondaryTradeStorage().escrows[agreementId];
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Secondary trade — offer lifecycle
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Posts a secondary-trade offer on behalf of `forAddr` (relayer path). `sig` is `forAddr`'s
+ /// EIP-712 authorization over `params` + `forAddr` + `nonce`. Thin wrapper over the linked logic.
+ function postOffer(PostOfferParams calldata params, address forAddr, uint256 nonce, bytes memory sig) external nonReentrant returns (bytes32 offerId) {
+ return SecondaryTradeStorage.postOffer(params, forAddr, nonce, sig);
+ }
+
+ /// @notice Posts a secondary-trade offer. Thin wrapper over the linked SecondaryTradeStorage logic.
+ function postOffer(PostOfferParams calldata params) external nonReentrant returns (bytes32 offerId) {
+ return SecondaryTradeStorage.postOffer(params);
+ }
+
+ /// @notice Cancels a secondary-trade offer on behalf of `forAddr` (relayer path). `sig` is `forAddr`'s
+ /// EIP-712 authorization over `offerId` + `forAddr` + `nonce`. Thin wrapper over the linked logic.
+ function cancelOffer(bytes32 offerId, address forAddr, uint256 nonce, bytes memory sig) external nonReentrant {
+ return SecondaryTradeStorage.cancelOffer(offerId, forAddr, nonce, sig);
+ }
+
+ /// @notice Cancels a non-terminal offer and returns its uncommitted assets to the offeror
+ /// @dev Only the free pool (uncommitted units / consideration) is refunded/released. Settlements already
+ /// accepted stay ACCEPTED and resolve on their own — finalized normally, or voided via the two-party
+ /// voidSecondaryTradeAgreement / expiry path; their assets stay in DealManager custody until then.
+ /// @param offerId Offer to cancel
+ function cancelOffer(bytes32 offerId) external nonReentrant {
+ SecondaryTradeStorage.cancelOffer(offerId);
+ }
+
+ /// @notice Accepts a secondary-trade offer on behalf of `forAddr` (relayer path). `sig` is `forAddr`'s
+ /// EIP-712 authorization over `params` + `forAddr` + `nonce`. Thin wrapper over the linked logic.
+ function acceptOffer(AcceptOfferParams calldata params, address forAddr, uint256 nonce, bytes memory sig) external nonReentrant returns (bytes32 settlementAgreementId) {
+ return SecondaryTradeStorage.acceptOffer(params, forAddr, nonce, sig);
+ }
+
+ /// @notice Accepts (fully or partially) a secondary-trade offer. Thin wrapper over the linked logic.
+ function acceptOffer(AcceptOfferParams calldata params) external nonReentrant returns (bytes32 settlementAgreementId) {
+ return SecondaryTradeStorage.acceptOffer(params);
+ }
+
+ /// @notice Finalizes an accepted secondary-trade settlement. Thin wrapper over the linked logic.
+ /// @dev Secondary counterpart of finalizeDeal; the two paths are kept fully separate.
+ function finalizeSecondaryTradeAgreement(bytes32 agreementId) external nonReentrant {
+ SecondaryTradeStorage.finalizeSecondaryTradeAgreement(agreementId);
+ }
+
+ /// @notice Voids an expired secondary-trade settlement. Thin wrapper over the linked logic.
+ /// @dev Secondary counterpart of voidExpiredDeal; the two paths are kept fully separate.
+ function voidExpiredSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature) external nonReentrant {
+ SecondaryTradeStorage.voidExpiredSecondaryTradeAgreement(agreementId, signer, signature);
+ }
+
+ /// @notice Records a party's request to void an ACCEPTED secondary settlement before it is finalized or expires
+ /// @dev Finalizer-vouched request channel: the registry voids the agreement only once BOTH parties have
+ /// requested (or it is past expiry). The local escrow is settled only when that actually happens, keeping
+ /// DealManager and the registry in sync; a lone request just records intent and the counterparty can still finalize.
+ /// @param agreementId Settlement agreement to void
+ /// @param signer Caller's address (must equal msg.sender)
+ /// @param signature Caller's EIP-712 void signature, forwarded to the agreement registry
+ function voidSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature) external nonReentrant {
+ SecondaryTradeStorage.voidSecondaryTradeAgreement(agreementId, signer, signature);
+ }
+
+ /// @notice Relayer variant of voidSecondaryTradeAgreement: a relayer submits `signer`'s void request on
+ /// their behalf, authorized by `signer`'s EIP-712 signature over the request plus an unordered `nonce`.
+ function voidSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature, uint256 nonce, bytes memory authSig) external nonReentrant {
+ SecondaryTradeStorage.voidSecondaryTradeAgreement(agreementId, signer, signature, nonce, authSig);
+ }
+
+ /// @notice Syncs a secondary settlement that was voided directly in the agreement registry
+ /// @dev Callable by anyone; guards against double-void via the terminal-state checks
+ /// @param agreementId Settlement agreement that was already voided in the registry
+ function syncVoidedSecondaryTradeAgreement(bytes32 agreementId) external nonReentrant {
+ SecondaryTradeStorage.syncVoidedSecondaryTradeAgreement(agreementId);
+ }
+
/// @notice UUPS upgrade authorization
/// @dev MetaLeX releases new versions through the factory's reference implementation,
/// and the CyberCorp owner can decide if or when he wants to perform the upgrade
diff --git a/src/DealManagerFactory.sol b/src/DealManagerFactory.sol
index 0a34f694..29c1032f 100644
--- a/src/DealManagerFactory.sol
+++ b/src/DealManagerFactory.sol
@@ -59,6 +59,7 @@ contract DealManagerFactory is UUPSUpgradeable, BorgAuthACL {
event DealManagerDeployed(address dealManager, string version);
event RefImplementationSet(address refImplementation, string version);
+ event IntegratorSet(address indexed integrator, bool indexed approved, uint256 feeShare);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
@@ -134,6 +135,26 @@ contract DealManagerFactory is UUPSUpgradeable, BorgAuthACL {
DealManagerFactoryStorage.setPlatformPayable(platformPayable);
}
+ /// @notice Whether an integrator is whitelisted to receive a fee share (spec §12B.4)
+ function isIntegratorWhitelisted(address integrator) external view returns (bool) {
+ return DealManagerFactoryStorage.getIntegrator(integrator).approved;
+ }
+
+ /// @notice This integrator's share of the protocol fee (BASIS_POINTS = 100%)
+ function getIntegratorFeeShare(address integrator) external view returns (uint256) {
+ return DealManagerFactoryStorage.getIntegrator(integrator).feeShare;
+ }
+
+ /// @notice Set an integrator's whitelist status and its share of the protocol fee
+ /// @dev Only callable by addresses with the admin role. Pass approved=false to de-whitelist;
+ /// settlement then falls through to the unsplit platform-only flow (never reverts).
+ function setIntegrator(address integrator, bool approved, uint256 feeShare) external onlyOwner {
+ if (integrator == address(0)) revert ZeroAddress();
+ if (feeShare > DealManagerFactoryStorage.BASIS_POINTS) revert InvalidFeeRatio();
+ DealManagerFactoryStorage.setIntegrator(integrator, approved, feeShare);
+ emit IntegratorSet(integrator, approved, feeShare);
+ }
+
/// @notice Get the fee ratio
/// @return Fee ratio (same unit as BASIS_POINTS
function getDefaultFeeRatio() external view returns (uint256) {
diff --git a/src/IssuanceManager.sol b/src/IssuanceManager.sol
index 06d77dd8..0435af0e 100644
--- a/src/IssuanceManager.sol
+++ b/src/IssuanceManager.sol
@@ -170,29 +170,12 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable {
// beacon proxies because they are managed by the same company owner and are expected to
// share the same implementation or upgraded to a new version all at the same time.
// Maintenance-wise, since IssuanceManager itself is upgradeable, we don't need to worry about beacon ownership transfers
-
- address cyberCertPrinterRefImpl = IIssuanceManagerFactory(
- _upgradeFactory
- ).getCyberCertPrinterRefImplementation();
- UpgradeableBeacon beaconCertPrinter = new UpgradeableBeacon(
- cyberCertPrinterRefImpl,
- address(this)
- );
- emit CertPrinterBeaconImplementationUpgraded(cyberCertPrinterRefImpl);
-
- address cyberScripRefImpl = IIssuanceManagerFactory(_upgradeFactory)
- .getCyberScripRefImplementation();
- UpgradeableBeacon beaconScrip = new UpgradeableBeacon(
- cyberScripRefImpl,
- address(this)
+ // Delegated to IssuanceManagerStorage to keep this contract under the EIP-170 size limit.
+ IssuanceManagerStorage.executeInitialize(
+ _upgradeFactory,
+ _CORP,
+ _uriBuilder
);
- emit ScripBeaconImplementationUpgraded(cyberScripRefImpl);
-
- IssuanceManagerStorage.setCORP(_CORP);
- IssuanceManagerStorage.setUriBuilder(_uriBuilder);
- IssuanceManagerStorage.setCyberCertPrinterBeacon(beaconCertPrinter);
- IssuanceManagerStorage.setUpgradeFactory(_upgradeFactory);
- IssuanceManagerStorage.setCyberScripBeacon(beaconScrip);
}
modifier onlyUpgradeFactory() {
@@ -413,6 +396,13 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable {
);
}
+ /// @notice Effectuates the secondary-trade ownership change at finalization (spec §7.4A / §7.5)
+ /// @dev Gated on OWNER_ROLE, which the SPV's DealManager holds. dealMetadata is the abi-encoded tuple
+ /// produced by DealManager.finalizeSecondaryTradeAgreement; see IssuanceManagerStorage.executeSecondaryTransfer.
+ function secondaryTransfer(bytes calldata dealMetadata) external onlyOwner {
+ IssuanceManagerStorage.executeSecondaryTransfer(dealMetadata);
+ }
+
/* /// @notice Updates the details of an existing certificate
/// @dev Only callable by admin
/// @param certAddress Address of the certificate printer contract
@@ -563,6 +553,12 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable {
return IssuanceManagerStorage.getPrinters()[index];
}
+ /// @notice Whether `printer` is a certificate printer this IssuanceManager created and still tracks
+ /// @dev Authoritative membership check against the registry; a self-reporting printer cannot forge it
+ function isPrinter(address printer) external view returns (bool) {
+ return IssuanceManagerStorage.isPrinter(printer);
+ }
+
/// @notice Sets the URI builder contract address
/// @dev Only callable by owner
/// @param _uriBuilder New URI builder contract address
@@ -637,6 +633,34 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable {
);
}
+ /// @notice Reserve units of a certificate against a pending deal/loan
+ /// @dev Reverts in the printer if the total reserved would exceed the cert's units
+ function increaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeIncreaseUnitsReserved(
+ certAddress,
+ tokenId,
+ amount
+ );
+ }
+
+ /// @notice Release previously reserved units of a certificate
+ /// @dev Reverts in the printer if releasing more than is currently reserved
+ function decreaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeDecreaseUnitsReserved(
+ certAddress,
+ tokenId,
+ amount
+ );
+ }
+
/// @notice Sets the minimum scrip amount required to convert back into certs
/// @dev Only callable by owner; set to 0 to disable the minimum
/// @param certAddress Address of the certificate printer contract
@@ -739,6 +763,44 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable {
);
}
+ /// @notice Adds a structured default restrictive legend to a certificate contract
+ /// @dev Only callable by admin
+ function addDefaultRestrictiveLegend(
+ address certAddress,
+ RestrictiveLegend memory newLegend
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeAddDefaultRestrictiveLegend(certAddress, newLegend);
+ }
+
+ /// @notice Removes a structured default restrictive legend from a certificate contract
+ /// @dev Only callable by admin
+ function removeDefaultRestrictiveLegendAt(
+ address certAddress,
+ uint256 index
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeRemoveDefaultRestrictiveLegendAt(certAddress, index);
+ }
+
+ /// @notice Adds a structured restrictive legend to a specific certificate
+ /// @dev Only callable by admin
+ function addCertRestrictiveLegend(
+ address certAddress,
+ uint256 tokenId,
+ RestrictiveLegend memory newLegend
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeAddCertRestrictiveLegend(certAddress, tokenId, newLegend);
+ }
+
+ /// @notice Removes a structured restrictive legend from a specific certificate
+ /// @dev Only callable by admin
+ function removeCertRestrictiveLegendAt(
+ address certAddress,
+ uint256 tokenId,
+ uint256 index
+ ) external onlyAdmin {
+ IssuanceManagerStorage.executeRemoveCertRestrictiveLegendAt(certAddress, tokenId, index);
+ }
+
//deploy matching erc20 contract for a cert
function deployCyberScrip(
address certAddress,
diff --git a/src/IssuanceManagerWithMigration.sol b/src/IssuanceManagerWithMigration.sol
index 7c0aef81..33953765 100644
--- a/src/IssuanceManagerWithMigration.sol
+++ b/src/IssuanceManagerWithMigration.sol
@@ -39,42 +39,43 @@ distributed, transmitted, sublicensed, sold, or otherwise used in any form or by
mechanical, including photocopying, recording, or by any information storage and retrieval system,
except with the express prior written permission of the copyright holder.*/
-pragma solidity 0.8.28;
-
-import {UpgradeableBeacon} from "openzeppelin-contracts/proxy/beacon/UpgradeableBeacon.sol";
-import {IssuanceManager} from "./IssuanceManager.sol";
-import {IssuanceManagerStorage} from "./storage/IssuanceManagerStorage.sol";
-import {IIssuanceManagerFactory} from "./interfaces/IIssuanceManagerFactory.sol";
-
-contract IssuanceManagerWithMigration is IssuanceManager {
-
- address public constant NEW_UPGRADE_FACTORY = 0xD353972D7955F421d94d0eA8c42c88c417F7155A; // TODO TBD
-
- /// @notice Migrate legacy contracts and set upgradeFactory to the known new contract (for reference implementation lookup)
- /// Also migrate its beacons for CyberCertPrinter and CyberScrip to new reference implementations
- /// @dev Since the migration target is predefined, it doesn't matter who called it or when it is called
- /// Note older contracts may not have CyberScrip beacon setup yet, in such case we will create it for them
- function migrateUpgradeFactory() public {
- IssuanceManagerStorage.setUpgradeFactory(NEW_UPGRADE_FACTORY);
-
- address cyberCertPrinterRefImpl = IIssuanceManagerFactory(NEW_UPGRADE_FACTORY).getCyberCertPrinterRefImplementation();
- IssuanceManagerStorage.upgradeCertPrinterBeaconImplementation(cyberCertPrinterRefImpl);
- emit CertPrinterBeaconImplementationUpgraded(cyberCertPrinterRefImpl);
-
- address cyberScripRefImpl = IIssuanceManagerFactory(NEW_UPGRADE_FACTORY).getCyberScripRefImplementation();
- if (address(IssuanceManagerStorage.getCyberScripBeacon()) == address(0)) {
- // Legacy contract does not have CyberScripBeacon set yet, create it for them
- UpgradeableBeacon beaconScrip = new UpgradeableBeacon(
- cyberScripRefImpl,
- address(this)
- );
- IssuanceManagerStorage.setCyberScripBeacon(beaconScrip);
- emit ScripBeaconImplementationUpgraded(cyberScripRefImpl);
-
- } else {
- // Legacy contract has CyberScripBeacon set, update it the regular way
- IssuanceManagerStorage.updateScripBeaconImplementation(cyberScripRefImpl);
- emit ScripBeaconImplementationUpgraded(cyberScripRefImpl);
- }
- }
-}
+// TODO commented out for now due to contract size violation
+//pragma solidity 0.8.28;
+//
+//import {UpgradeableBeacon} from "openzeppelin-contracts/proxy/beacon/UpgradeableBeacon.sol";
+//import {IssuanceManager} from "./IssuanceManager.sol";
+//import {IssuanceManagerStorage} from "./storage/IssuanceManagerStorage.sol";
+//import {IIssuanceManagerFactory} from "./interfaces/IIssuanceManagerFactory.sol";
+//
+//contract IssuanceManagerWithMigration is IssuanceManager {
+//
+// address public constant NEW_UPGRADE_FACTORY = 0xD353972D7955F421d94d0eA8c42c88c417F7155A; // TODO TBD
+//
+// /// @notice Migrate legacy contracts and set upgradeFactory to the known new contract (for reference implementation lookup)
+// /// Also migrate its beacons for CyberCertPrinter and CyberScrip to new reference implementations
+// /// @dev Since the migration target is predefined, it doesn't matter who called it or when it is called
+// /// Note older contracts may not have CyberScrip beacon setup yet, in such case we will create it for them
+// function migrateUpgradeFactory() public {
+// IssuanceManagerStorage.setUpgradeFactory(NEW_UPGRADE_FACTORY);
+//
+// address cyberCertPrinterRefImpl = IIssuanceManagerFactory(NEW_UPGRADE_FACTORY).getCyberCertPrinterRefImplementation();
+// IssuanceManagerStorage.upgradeCertPrinterBeaconImplementation(cyberCertPrinterRefImpl);
+// emit CertPrinterBeaconImplementationUpgraded(cyberCertPrinterRefImpl);
+//
+// address cyberScripRefImpl = IIssuanceManagerFactory(NEW_UPGRADE_FACTORY).getCyberScripRefImplementation();
+// if (address(IssuanceManagerStorage.getCyberScripBeacon()) == address(0)) {
+// // Legacy contract does not have CyberScripBeacon set yet, create it for them
+// UpgradeableBeacon beaconScrip = new UpgradeableBeacon(
+// cyberScripRefImpl,
+// address(this)
+// );
+// IssuanceManagerStorage.setCyberScripBeacon(beaconScrip);
+// emit ScripBeaconImplementationUpgraded(cyberScripRefImpl);
+//
+// } else {
+// // Legacy contract has CyberScripBeacon set, update it the regular way
+// IssuanceManagerStorage.updateScripBeaconImplementation(cyberScripRefImpl);
+// emit ScripBeaconImplementationUpgraded(cyberScripRefImpl);
+// }
+// }
+//}
diff --git a/src/RoundManager.sol b/src/RoundManager.sol
index 02ea6546..f446a6ba 100644
--- a/src/RoundManager.sol
+++ b/src/RoundManager.sol
@@ -46,7 +46,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./interfaces/IIssuanceManager.sol";
-import "./libs/LexScroWLite.sol";
+import "./interfaces/ILexScrowStorage.sol";
import "./libs/auth.sol";
import "./libs/EIP712Lib.sol";
import "./storage/RoundManagerStorage.sol";
@@ -64,9 +64,9 @@ import "./interfaces/ILexChex.sol";
contract RoundManager is
Initializable,
BorgAuthACL,
- LexScroWLite,
UUPSUpgradeable,
- ReentrancyGuard
+ ReentrancyGuard,
+ ILexScrowStorage
{
using RoundManagerStorage for RoundManagerStorage.RoundManagerData;
using LexScrowStorage for LexScrowStorage.LexScrowData;
@@ -82,7 +82,6 @@ contract RoundManager is
error InvalidParties();
error InvalidCertPrinter();
error InvalidCert();
- error AgreementConditionsNotMet();
error ZeroAddress();
error InvalidIssuanceManager();
error InvalidEscrowedSignature();
@@ -165,7 +164,9 @@ contract RoundManager is
) public initializer {
__UUPSUpgradeable_init();
__BorgAuthACL_init(_auth);
- __LexScroWLite_init(_corp, _dealRegistry);
+ // LexScrowStorage is now a library — set escrow core addresses in storage directly
+ LexScrowStorage.setCorp(_corp);
+ LexScrowStorage.setDealRegistry(_dealRegistry);
if (_corp == address(0)) revert ZeroAddress();
if (_dealRegistry == address(0)) revert ZeroAddress();
@@ -394,7 +395,7 @@ contract RoundManager is
);
// Interaction: payments
- handleCounterPartyPayment(agreementId);
+ LexScrowStorage.handleCounterPartyPayment(agreementId);
emit EOISubmitted(agreementId, roundId, msg.sender, LexScrowStorage.getCorp(), eoi.minAmount, eoi.maxAmount, eoi.expiry);
@@ -444,7 +445,7 @@ contract RoundManager is
allocatedAmount = candidate;
// Check: status
- if (escrow.status != EscrowStatus.PAID) revert DealNotPaid();
+ if (escrow.status != EscrowStatus.PAID) revert LexScrowStorage.DealNotPaid();
if (escrow.corpAssets.length > 0) revert AlreadyAllocated();
(uint256 tokenId, uint256[] memory certIds, uint256 usedAmount, uint256 refund) = RoundManagerStorage.allocate(
@@ -454,7 +455,7 @@ contract RoundManager is
);
// Check: Check conditions
- if (!conditionCheck(agreementId)) revert AgreementConditionsNotMet();
+ if (!LexScrowStorage.conditionCheck(agreementId)) revert ILexScrowStorage.AgreementConditionsNotMet();
// Effect: Finalize agreement
ICyberAgreementRegistry(LexScrowStorage.getDealRegistry())
@@ -473,7 +474,7 @@ contract RoundManager is
}
// Interaction: Finalize escrow and payments
- finalizeEscrow(agreementId);
+ LexScrowStorage.finalizeEscrow(agreementId);
emit AllocationMade(agreementId, roundId, escrow.counterParty, usedAmount, round.raised, certIds);
@@ -499,11 +500,11 @@ contract RoundManager is
// Check: check status
if (round.id == bytes32(0)) revert InvalidRound();
- if (escrow.status != EscrowStatus.PAID) revert DealNotPaid();
+ if (escrow.status != EscrowStatus.PAID) revert LexScrowStorage.DealNotPaid();
if (escrow.corpAssets.length > 0) revert AlreadyAllocated();
// Effect: update status
- voidEscrow(agreementId);
+ LexScrowStorage.voidEscrow(agreementId);
if (isVoidAgreement) {
ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, escrow.counterParty, escrow.signature);
@@ -536,12 +537,12 @@ contract RoundManager is
// Check: check status
if (round.id == bytes32(0)) revert InvalidRound();
if (msg.sender != escrow.counterParty) revert NotEOISubmitter();
- if (escrow.status != EscrowStatus.PAID) revert DealNotPaid();
+ if (escrow.status != EscrowStatus.PAID) revert LexScrowStorage.DealNotPaid();
if (escrow.corpAssets.length > 0) revert AlreadyAllocated();
if (block.timestamp < escrow.expiry && round.endTime > block.timestamp) revert EOINotExpired();
// Effect: update status
- voidEscrow(agreementId);
+ LexScrowStorage.voidEscrow(agreementId);
// void agreement
if (isVoidAgreement) {
@@ -567,17 +568,42 @@ contract RoundManager is
/// @dev Currently the factory owner (MetaLeX) unilaterally set the fee ratio;
/// in the future, it could be determined through a governance process.
/// @return Fee amount
- function computeFee(uint256 size) public override view returns (uint256) {
+ function computeFee(uint256 size) public view returns (uint256) {
return size * IRoundManagerFactory(RoundManagerStorage.getUpgradeFactory()).getDefaultFeeRatio() / RoundManagerFactoryStorage.BASIS_POINTS;
}
/// @notice Gets the payable address for the fees
/// @dev The factory owner (MetaLeX) unilaterally set the payable address
/// @return Payable address for the fees
- function getPlatformPayable() public override view returns (address) {
+ function getPlatformPayable() public view returns (address) {
return IRoundManagerFactory(RoundManagerStorage.getUpgradeFactory()).getPlatformPayable();
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // LexScrowStorage surface — thin wrappers (LexScrowStorage is now a library) so the proxy keeps
+ // exposing these selectors for off-chain callers and condition contracts (ILexScrowStorage).
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Get escrow details for a given agreement id
+ function getEscrowDetails(bytes32 agreementId) public view returns (Escrow memory) {
+ return LexScrowStorage.getEscrow(agreementId);
+ }
+
+ /// @notice Check all conditions attached to the escrow for the given agreement
+ function conditionCheck(bytes32 agreementId) public view returns (bool) {
+ return LexScrowStorage.conditionCheck(agreementId);
+ }
+
+ /// @notice ERC721 receiver hook for safe transfers into escrow (moved here from LexScrowStorage)
+ function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC721Received.selector;
+ }
+
+ /// @notice ERC1155 receiver hook for safe transfers into escrow (moved here from LexScrowStorage)
+ function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC1155Received.selector;
+ }
+
/// @notice UUPS upgrade authorization
/// @dev MetaLeX releases new versions through the factory's reference implementation,
/// and the CyberCorp owner can decide if or when he wants to perform the upgrade
diff --git a/src/creds/lexchexBadge.sol b/src/creds/lexchexBadge.sol
new file mode 100644
index 00000000..543eb3b0
--- /dev/null
+++ b/src/creds/lexchexBadge.sol
@@ -0,0 +1,603 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+pragma solidity 0.8.28;
+
+import "openzeppelin-contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
+import "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
+import "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "openzeppelin-contracts/utils/Strings.sol";
+import "openzeppelin-contracts/utils/Base64.sol";
+import "openzeppelin-contracts/token/ERC721/IERC721.sol";
+import "./storage/lexchexBadgeStorage.sol";
+import "../libs/auth.sol";
+import "../interfaces/IERC5484.sol";
+import "../interfaces/ILexChexBadge.sol";
+
+/// @title LeXcheXBadge - Unified Soulbound Credential Contract (LeXcheX v2)
+/// @author MetaLeX Labs, Inc.
+/// @notice Generalizes the single-purpose LeXcheX U.S. Accredited Investor certificate into one soulbound
+/// credential registry covering all credentialing and whitelisting in the cyberTRADE spec: KYC/AML,
+/// accredited investor, qualified purchaser, QIB, non-U.S. person, bad-actor negative attestation, per-SPV
+/// whitelist entitlements, syndicate circles, and custom issuer-defined tiers — plus the §4.1.3A credential
+/// attributes (U.S. state of residence/organization and entity beneficial-owner count).
+/// @dev One deployment = one credentialing layer under one operator's BorgAuth (§4.1.3A layer model).
+/// Pure state: no per-trade compliance logic (conditions do that), no offer-visibility enforcement, no KYC
+/// itself, no delegation — credentials attach to the verified wallet only.
+contract LeXcheXBadge is
+ Initializable,
+ ERC721EnumerableUpgradeable,
+ UUPSUpgradeable,
+ BorgAuthACL,
+ ILexChexBadge
+{
+ using Strings for uint256;
+
+ uint256 public constant VERSION = 2;
+
+ /// @dev Default when a category does not exist (mirrors LeXcheX v1's constant BurnAuth)
+ BurnAuth constant DEFAULT_BURNAUTH = BurnAuth.OwnerOnly;
+
+ // Upgrade notes: Reduced gap to account for new variables (50 - 1 = 49)
+ uint256[49] private __gap;
+
+ // Custom errors
+ error LexChexBadge_SoulBound();
+ error LexChexBadge_TokenDoesNotExist();
+ error LexChexBadge_TokenCannotBeBurned();
+ error LexChexBadge_OnlyIssuerCanBurn();
+ error LexChexBadge_OnlyOwnerCanBurn();
+ error LexChexBadge_CategoryDoesNotExist();
+ error LexChexBadge_CategoryAlreadyExists();
+ error LexChexBadge_CategoryNotActive();
+ error LexChexBadge_InvalidValidityConfig();
+ error LexChexBadge_MissingUsState();
+ error LexChexBadge_UsStateNotAllowedForNonUS();
+ error LexChexBadge_MissingBeneficialOwnerCount();
+ error LexChexBadge_MissingEvidenceHash();
+
+ // Events (indexer surface: Ponder ingests these for /api/offers eligibility and the admin panel §8.7)
+ event CategoryCreated(bytes32 indexed categoryId, CredentialCategory category);
+ event CategoryUpdated(bytes32 indexed categoryId, CredentialCategory category);
+ event CategoryRetired(bytes32 indexed categoryId);
+ event CredentialIssued(address indexed owner, uint256 indexed tokenId, bytes32 indexed categoryId, Credential cred);
+ event CredentialRecertified(address indexed owner, uint256 indexed tokenId, Credential cred);
+ event CredentialAttributesUpdated(uint256 indexed tokenId, bytes2 usState, uint32 beneficialOwnerCount);
+ event CredentialVoided(address indexed owner, uint256 indexed tokenId, string reason);
+ event CredentialBurned(address indexed owner, uint256 indexed tokenId);
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth) public initializer {
+ __BorgAuthACL_init(_auth);
+ __ERC721_init("LeXcheX Badge", "LXB");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Category / schema system (§0.3)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Registers a new issuer-defined credential category
+ function createCategory(bytes32 categoryId, CredentialCategory memory category) external onlyAdmin {
+ if (LeXcheXBadgeStorage.getCategory(categoryId).exists) revert LexChexBadge_CategoryAlreadyExists();
+ category.exists = true;
+ category.active = true;
+ LeXcheXBadgeStorage.setCategory(categoryId, category);
+ emit CategoryCreated(categoryId, category);
+ }
+
+ /// @notice Updates an existing category's schema (does not touch outstanding credentials)
+ function updateCategory(bytes32 categoryId, CredentialCategory memory category) external onlyAdmin {
+ CredentialCategory storage existing = LeXcheXBadgeStorage.getCategory(categoryId);
+ if (!existing.exists) revert LexChexBadge_CategoryDoesNotExist();
+ category.exists = true;
+ LeXcheXBadgeStorage.setCategory(categoryId, category);
+ emit CategoryUpdated(categoryId, category);
+ }
+
+ /// @notice Retires a category: stops new issuance but does not void outstanding credentials
+ /// (void them explicitly if intended)
+ function retireCategory(bytes32 categoryId) external onlyAdmin {
+ CredentialCategory storage existing = LeXcheXBadgeStorage.getCategory(categoryId);
+ if (!existing.exists) revert LexChexBadge_CategoryDoesNotExist();
+ existing.active = false;
+ emit CategoryRetired(categoryId);
+ }
+
+ function getCategory(bytes32 categoryId) public view returns (CredentialCategory memory) {
+ return LeXcheXBadgeStorage.getCategory(categoryId);
+ }
+
+ function getCategoryIds() external view returns (bytes32[] memory) {
+ return LeXcheXBadgeStorage.getCategoryIds();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Lifecycle entry points (§0.5)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Issues a credential under an active category. Validates required attributes per the
+ /// category's flags, stamps issuanceDate, and computes expiryDate from the category default if unset.
+ function mint(
+ address to,
+ bytes32 categoryId,
+ Credential memory cred
+ ) public onlyAdmin returns (uint256 tokenId) {
+ CredentialCategory storage category = LeXcheXBadgeStorage.getCategory(categoryId);
+ if (!category.exists) revert LexChexBadge_CategoryDoesNotExist();
+ if (!category.active) revert LexChexBadge_CategoryNotActive();
+
+ cred.categoryId = categoryId;
+ cred.issuanceDate = uint64(block.timestamp);
+ if (cred.expiryDate == 0) {
+ if (category.defaultValidityDuration == 0) revert LexChexBadge_InvalidValidityConfig();
+ cred.expiryDate = uint64(block.timestamp) + category.defaultValidityDuration;
+ }
+ _validateRequiredAttributes(category, cred);
+
+ tokenId = LeXcheXBadgeStorage.getSupply();
+ _mint(to, tokenId);
+ LeXcheXBadgeStorage.setCredential(tokenId, cred);
+ LeXcheXBadgeStorage.incrementSupply();
+
+ emit CredentialIssued(to, tokenId, categoryId, cred);
+ emit Issued(address(0), to, tokenId, category.burnAuth);
+ }
+
+ /// @notice The §6.9 refresh path: updates attributes and extends expiryDate in place, preserving
+ /// issuanceDate (so seasoning is not reset by routine recertification) and the original category.
+ function recertify(uint256 tokenId, Credential memory cred) external onlyAdmin {
+ Credential storage existing = LeXcheXBadgeStorage.getCredential(tokenId);
+ if (existing.issuanceDate == 0) revert LexChexBadge_TokenDoesNotExist();
+ CredentialCategory storage category = LeXcheXBadgeStorage.getCategory(existing.categoryId);
+
+ // Preserve the seasoning anchor and category link
+ cred.categoryId = existing.categoryId;
+ cred.issuanceDate = existing.issuanceDate;
+ if (cred.expiryDate == 0) {
+ if (category.defaultValidityDuration == 0) revert LexChexBadge_InvalidValidityConfig();
+ cred.expiryDate = uint64(block.timestamp) + category.defaultValidityDuration;
+ }
+ _validateRequiredAttributes(category, cred);
+
+ LeXcheXBadgeStorage.setCredential(tokenId, cred);
+ emit CredentialRecertified(_requireOwned(tokenId), tokenId, cred);
+ }
+
+ /// @notice Material-change path for usState and beneficialOwnerCount between recertifications.
+ /// @dev Per §6.9, a holder-reported state change routes through recertify (the credential is voided if
+ /// the state is changed without recertification); this is reserved for corrections and BO-count refreshes.
+ function updateAttributes(uint256 tokenId, bytes2 usState, uint32 beneficialOwnerCount) external onlyAdmin {
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ if (cred.issuanceDate == 0) revert LexChexBadge_TokenDoesNotExist();
+ cred.usState = usState;
+ cred.beneficialOwnerCount = beneficialOwnerCount;
+ emit CredentialAttributesUpdated(tokenId, usState, beneficialOwnerCount);
+ }
+
+ /// @notice Revocation: failed re-KYC, discovered bad-actor status, relocation without recertification,
+ /// sanctions hits. Reason string recorded and emitted.
+ function void(uint256 tokenId, string memory reason) external onlyOwner {
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ if (cred.issuanceDate == 0) revert LexChexBadge_TokenDoesNotExist();
+ cred.voided = reason;
+ emit CredentialVoided(_requireOwned(tokenId), tokenId, reason);
+ }
+
+ /// @notice Burns a credential, gated by the category's BurnAuth (default OwnerOnly, matching LeXcheX v1;
+ /// IssuerOnly for whitelist/syndicate categories so a holder cannot self-remove and re-onboard to reset seasoning)
+ function burn(uint256 tokenId) public {
+ address owner = _requireOwned(tokenId);
+ BurnAuth auth = burnAuth(tokenId);
+
+ if (auth == BurnAuth.Neither) revert LexChexBadge_TokenCannotBeBurned();
+ if (auth == BurnAuth.OwnerOnly && msg.sender != owner) revert LexChexBadge_OnlyOwnerCanBurn();
+ if (auth == BurnAuth.IssuerOnly && !_isIssuer(msg.sender)) revert LexChexBadge_OnlyIssuerCanBurn();
+ if (auth == BurnAuth.Both && msg.sender != owner && !_isIssuer(msg.sender)) {
+ revert LexChexBadge_OnlyOwnerCanBurn();
+ }
+
+ _burn(tokenId);
+ LeXcheXBadgeStorage.deleteCredential(tokenId);
+ emit CredentialBurned(owner, tokenId);
+ }
+
+ /// @inheritdoc IERC5484
+ function burnAuth(uint256 tokenId) public view override returns (BurnAuth) {
+ CredentialCategory storage category =
+ LeXcheXBadgeStorage.getCategory(LeXcheXBadgeStorage.getCredential(tokenId).categoryId);
+ return category.exists ? category.burnAuth : DEFAULT_BURNAUTH;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Read interface (§0.6) — what conditions and the UI consume
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Three-part validity test carried over from v1: issued, not voided, not expired
+ function isValid(uint256 tokenId) public view returns (bool) {
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ if (cred.issuanceDate == 0) return false;
+ if (bytes(cred.voided).length > 0) return false;
+ if (block.timestamp > cred.expiryDate) return false;
+ return true;
+ }
+
+ /// @notice The workhorse for KYCAMLCondition, LegionSoulboundCondition, and whitelist checks
+ function hasValidCredential(address owner, bytes32 categoryId) public view returns (bool) {
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ uint256 tokenId = tokenOfOwnerByIndex(owner, i);
+ if (LeXcheXBadgeStorage.getCredential(tokenId).categoryId == categoryId && isValid(tokenId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// @notice Serves the LexChexCondition parameterizations (AccreditedInvestor / QP / QIB) without
+ /// hardcoding category IDs. Empty investorTypeFilter matches any investor type.
+ function hasValidCredentialOfKind(
+ address owner,
+ CategoryKind kind,
+ string memory investorTypeFilter
+ ) public view returns (bool) {
+ bytes32 filterHash = keccak256(bytes(investorTypeFilter));
+ bool hasFilter = bytes(investorTypeFilter).length > 0;
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ uint256 tokenId = tokenOfOwnerByIndex(owner, i);
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ CredentialCategory storage category = LeXcheXBadgeStorage.getCategory(cred.categoryId);
+ if (!category.exists || category.kind != kind) continue;
+ if (hasFilter && keccak256(bytes(cred.investorType)) != filterHash) continue;
+ if (isValid(tokenId)) return true;
+ }
+ return false;
+ }
+
+ /// @notice Resolves SPV_WHITELIST / SYNDICATE categories scoped to the SPV. Backs per-SPV
+ /// offer-visibility entitlements (§16.2) and any onchain issuer gating.
+ function hasValidWhitelistFor(address owner, address spv) public view returns (bool) {
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ uint256 tokenId = tokenOfOwnerByIndex(owner, i);
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ CredentialCategory storage category = LeXcheXBadgeStorage.getCategory(cred.categoryId);
+ if (!category.exists) continue;
+ if (category.kind != CategoryKind.SPV_WHITELIST && category.kind != CategoryKind.SYNDICATE) continue;
+ if (category.scope != spv) continue;
+ if (isValid(tokenId)) return true;
+ }
+ return false;
+ }
+
+ /// @notice U.S. state attribute for USStateOfResidenceCondition; zero for non-U.S. holders.
+ /// Sourced from the owner's most recent valid credential carrying the attribute.
+ function getUsState(address owner) public view returns (bytes2) {
+ (uint256 tokenId, bool found) = _mostRecentValidWith(owner, _HAS_US_STATE);
+ return found ? LeXcheXBadgeStorage.getCredential(tokenId).usState : bytes2(0);
+ }
+
+ /// @notice Entity beneficial-owner count for HolderCapCondition's §3(c)(1)(A) look-through; 0 when absent.
+ function getBeneficialOwnerCount(address owner) public view returns (uint32) {
+ (uint256 tokenId, bool found) = _mostRecentValidWith(owner, _HAS_BO_COUNT);
+ return found ? LeXcheXBadgeStorage.getCredential(tokenId).beneficialOwnerCount : 0;
+ }
+
+ /// @notice Country jurisdiction from the owner's most recent valid credential; empty string when none.
+ function getInvestorJurisdiction(address owner) public view returns (string memory) {
+ (uint256 tokenId, bool found) = _mostRecentValidWith(owner, _ANY);
+ return found ? LeXcheXBadgeStorage.getCredential(tokenId).investorJurisdiction : "";
+ }
+
+ /// @notice Seasoning reference for the UI (§11.1B): earliest valid issuance of the given kind.
+ /// The seasoning policy (30 vs 45 days) stays at the UI layer; this only supplies the timestamp.
+ function earliestValidIssuance(address owner, CategoryKind kind) public view returns (uint64) {
+ uint64 earliest = 0;
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ uint256 tokenId = tokenOfOwnerByIndex(owner, i);
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ if (!isValid(tokenId)) continue;
+ if (LeXcheXBadgeStorage.getCategory(cred.categoryId).kind != kind) continue;
+ if (earliest == 0 || cred.issuanceDate < earliest) earliest = cred.issuanceDate;
+ }
+ return earliest;
+ }
+
+ // ── Carried over from LeXcheX v1 (interface-compatible reads, §0.10) ─────
+
+ /// @notice v1-compatible read: true when the owner holds any valid credential
+ function hasValidLexCheX(address owner) public view returns (bool) {
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ if (isValid(tokenOfOwnerByIndex(owner, i))) return true;
+ }
+ return false;
+ }
+
+ function getTokenIdsByOwner(address owner) public view returns (uint256[] memory) {
+ uint256 balance = balanceOf(owner);
+ uint256[] memory tokenIds = new uint256[](balance);
+ for (uint256 i = 0; i < balance; i++) {
+ tokenIds[i] = tokenOfOwnerByIndex(owner, i);
+ }
+ return tokenIds;
+ }
+
+ /// @notice First token ID owned by an address (v1 getAccreditationByOwner-style getter)
+ function getCredentialByOwner(address owner) public view returns (uint256) {
+ require(balanceOf(owner) > 0, "No tokens owned by this address");
+ return tokenOfOwnerByIndex(owner, 0);
+ }
+
+ function getCredential(uint256 tokenId) public view returns (Credential memory) {
+ return LeXcheXBadgeStorage.getCredential(tokenId);
+ }
+
+ /// @dev Disambiguates balanceOf, declared by both ERC721Upgradeable and ILexChexBadge.
+ function balanceOf(address owner)
+ public
+ view
+ override(ERC721Upgradeable, IERC721, ILexChexBadge)
+ returns (uint256)
+ {
+ return super.balanceOf(owner);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Soulbound enforcement (§0.1)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @dev Reverts on any transfer where from != 0 && to != 0 (mint and burn only), identical to the
+ /// existing LexChex_SoulBound() pattern
+ function _update(
+ address to,
+ uint256 tokenId,
+ address auth
+ ) internal virtual override returns (address) {
+ address from = _ownerOf(tokenId);
+ if (from != address(0) && to != address(0)) {
+ revert LexChexBadge_SoulBound();
+ }
+ return super._update(to, tokenId, auth);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // tokenURI (§0.8) — per-category title/description; sensitive attributes
+ // (usState, beneficialOwnerCount, evidenceHash) are NOT rendered
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
+ Credential memory cred = LeXcheXBadgeStorage.getCredential(tokenId);
+ CredentialCategory memory category = LeXcheXBadgeStorage.getCategory(cred.categoryId);
+ string memory title = category.exists ? category.name : "LeXcheX Credential";
+
+ string memory image = generateSVGImage(title, cred);
+
+ return string(
+ abi.encodePacked(
+ "data:application/json;base64,",
+ Base64.encode(
+ bytes(
+ abi.encodePacked(
+ '{"name": "',
+ title,
+ " #",
+ tokenId.toString(),
+ '", "description": "',
+ category.exists ? category.description : "Soulbound credential issued on the LeXcheX Badge registry.",
+ '",',
+ '"image": "data:image/svg+xml;base64,',
+ Base64.encode(bytes(image)),
+ '", "attributes": [',
+ '{"trait_type": "Name", "value": "',
+ cred.investorName,
+ '"},',
+ '{"trait_type": "Entity Type", "value": "',
+ cred.investorType,
+ '"},',
+ '{"trait_type": "Jurisdiction", "value": "',
+ cred.investorJurisdiction,
+ '"},',
+ '{"trait_type": "Status", "value": "',
+ isValid(tokenId) ? "Valid" : "Invalid",
+ '"},',
+ '{"trait_type": "Expiry", "value": "',
+ timestampToDate(cred.expiryDate),
+ '"}',
+ "]}"
+ )
+ )
+ )
+ )
+ );
+ }
+
+ function generateSVGImage(string memory title, Credential memory cred) internal pure returns (string memory) {
+ return string(
+ abi.encodePacked(
+ '"
+ )
+ );
+ }
+
+ function generateMetaLeXLogo() internal pure returns (string memory) {
+ return string(
+ abi.encodePacked(
+ '"
+ )
+ );
+ }
+
+ function generateSVGBody(string memory title, Credential memory cred) internal pure returns (string memory) {
+ return string(
+ abi.encodePacked(
+ '',
+ '',
+ title,
+ "",
+ 'THIS SOULBOUND CREDENTIAL IS HELD BY',
+ '',
+ cred.investorName,
+ "",
+ generateDefs(),
+ '',
+ 'JURISDICTION',
+ '',
+ cred.investorJurisdiction,
+ "",
+ '',
+ 'GOOD UNTIL',
+ '',
+ timestampToDate(cred.expiryDate),
+ "",
+ '',
+ 'Non-transferable. Soul-bound. Verified on-chain.',
+ '',
+ bytes32ToHexString(cred.agreementId),
+ ""
+ )
+ );
+ }
+
+ function generateDefs() internal pure returns (string memory) {
+ return string(
+ abi.encodePacked(
+ "",
+ '',
+ '',
+ '',
+ "",
+ '',
+ '',
+ '',
+ '',
+ '',
+ "",
+ '',
+ '',
+ '',
+ "",
+ ""
+ )
+ );
+ }
+
+ function timestampToDate(uint256 timestamp) internal pure returns (string memory) {
+ uint256 day = ((timestamp / 86400) % 31) + 1;
+ uint256 month = ((timestamp / 2629743) % 12) + 1;
+ uint256 year = (timestamp / 31556926) + 1970;
+ return string(
+ abi.encodePacked(
+ Strings.toString(month), "/", Strings.toString(day), "/", Strings.toString(year)
+ )
+ );
+ }
+
+ function bytes32ToHexString(bytes32 value) internal pure returns (string memory) {
+ bytes memory str = new bytes(64);
+ for (uint256 i = 0; i < 32; i++) {
+ str[i * 2] = bytes1(
+ uint8(uint256(uint8(value[i] >> 4)) + (uint256(uint8(value[i] >> 4)) < 10 ? 48 : 87))
+ );
+ str[i * 2 + 1] = bytes1(
+ uint8(uint256(uint8(value[i] & 0x0f)) + (uint256(uint8(value[i] & 0x0f)) < 10 ? 48 : 87))
+ );
+ }
+ return string(abi.encodePacked("0x", string(str)));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Internals
+ // ─────────────────────────────────────────────────────────────────────────
+
+ uint8 private constant _ANY = 0;
+ uint8 private constant _HAS_US_STATE = 1;
+ uint8 private constant _HAS_BO_COUNT = 2;
+
+ /// @dev Most recent (highest issuanceDate) valid credential of `owner` carrying the requested attribute
+ function _mostRecentValidWith(address owner, uint8 attribute) internal view returns (uint256 tokenId, bool found) {
+ uint64 latest = 0;
+ uint256 balance = balanceOf(owner);
+ for (uint256 i = 0; i < balance; i++) {
+ uint256 candidate = tokenOfOwnerByIndex(owner, i);
+ if (!isValid(candidate)) continue;
+ Credential storage cred = LeXcheXBadgeStorage.getCredential(candidate);
+ if (attribute == _HAS_US_STATE && cred.usState == bytes2(0)) continue;
+ if (attribute == _HAS_BO_COUNT && cred.beneficialOwnerCount == 0) continue;
+ if (!found || cred.issuanceDate >= latest) {
+ latest = cred.issuanceDate;
+ tokenId = candidate;
+ found = true;
+ }
+ }
+ }
+
+ /// @dev Enforces the category's required-attribute flags on a credential being written
+ function _validateRequiredAttributes(CredentialCategory storage category, Credential memory cred) internal view {
+ if (category.requiresEvidenceHash && cred.evidenceHash == bytes32(0)) {
+ revert LexChexBadge_MissingEvidenceHash();
+ }
+ bool isUS = _isUSJurisdiction(cred.investorJurisdiction);
+ if (category.requiresUsState && isUS && cred.usState == bytes2(0)) {
+ revert LexChexBadge_MissingUsState();
+ }
+ // Keep the attribute clean for USStateOfResidenceCondition: non-U.S. holders carry no state
+ if (!isUS && cred.usState != bytes2(0)) {
+ revert LexChexBadge_UsStateNotAllowedForNonUS();
+ }
+ if (category.requiresBeneficialOwnerCount && !_isIndividual(cred.investorType) && cred.beneficialOwnerCount == 0) {
+ revert LexChexBadge_MissingBeneficialOwnerCount();
+ }
+ }
+
+ function _isUSJurisdiction(string memory jurisdiction) internal pure returns (bool) {
+ bytes32 h = keccak256(bytes(jurisdiction));
+ return h == keccak256("US") || h == keccak256("USA") || h == keccak256("United States");
+ }
+
+ function _isIndividual(string memory investorType) internal pure returns (bool) {
+ bytes32 h = keccak256(bytes(investorType));
+ return h == keccak256("Individual") || h == keccak256("individual");
+ }
+
+ /// @dev Issuer = the layer operator's BorgAuth admin (or above)
+ function _isIssuer(address account) internal view returns (bool) {
+ return AUTH.userRoles(account) >= AUTH.ADMIN_ROLE();
+ }
+
+ /// @dev Only owner can upgrade it
+ function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {}
+}
diff --git a/src/creds/storage/lexchexBadgeStorage.sol b/src/creds/storage/lexchexBadgeStorage.sol
new file mode 100644
index 00000000..f2a9310f
--- /dev/null
+++ b/src/creds/storage/lexchexBadgeStorage.sol
@@ -0,0 +1,134 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.*/
+
+pragma solidity 0.8.28;
+
+import "../../interfaces/IERC5484.sol";
+
+/// @notice Kind of credential a category attests to. Conditions filter on this without hardcoding
+/// category ids (see ILexChexBadge.hasValidCredentialOfKind).
+enum CategoryKind {
+ KYC_AML,
+ ACCREDITED_INVESTOR,
+ QUALIFIED_PURCHASER,
+ QIB,
+ NON_US_PERSON,
+ BAD_ACTOR_CLEAR,
+ SPV_WHITELIST,
+ SYNDICATE,
+ CUSTOM
+}
+
+/// @notice Issuer-defined credential category (schema) keyed by a bytes32 categoryId.
+struct CredentialCategory {
+ string name; // human-readable category name (rendered as certificate title)
+ string description; // rendered in tokenURI metadata
+ CategoryKind kind;
+ uint64 defaultValidityDuration; // seconds; drives expiryDate at mint when the credential's own is unset
+ bool requiresUsState; // credential must carry usState when the holder is US-jurisdiction
+ bool requiresBeneficialOwnerCount; // entity credentials must carry the §3(c)(1)(A) look-through count
+ bool requiresEvidenceHash; // credential must anchor an offchain diligence record
+ IERC5484.BurnAuth burnAuth; // per-category ERC-5484 burn auth (OwnerOnly default, IssuerOnly for whitelists)
+ address scope; // SPV cyberCORP address for SPV_WHITELIST / SYNDICATE; zero otherwise
+ bool active; // false = retired: no new issuance, outstanding credentials unaffected
+ bool exists;
+}
+
+/// @notice Per-token credential record; superset of the legacy LeXcheX Accreditation so v1 semantics carry over.
+struct Credential {
+ bytes32 categoryId; // links to the category registry
+ string investorName;
+ string investorType; // Individual / entity subtype; conditions filter on it
+ string investorJurisdiction; // country jurisdiction (ISO 3166-1 alpha-2 recommended, "US" for U.S.)
+ bytes2 usState; // §4.1.3A: U.S. state of residence/organization; zero for non-U.S. holders
+ uint32 beneficialOwnerCount; // §4.1.3A: entity holders only; §3(c)(1)(A) look-through count
+ uint64 issuanceDate; // validity anchor; also the seasoning reference (§11.1B)
+ uint64 expiryDate; // isValid fails after it
+ string voided; // non-empty = voided, with reason
+ bytes32 agreementId; // CyberAgreementRegistry attestation underlying the credential
+ bytes32 evidenceHash; // hash of the offchain diligence record (audit anchor)
+ bytes extensionData; // forward-compatible blob for future attributes
+}
+
+/// @title LeXcheXBadgeStorage - namespaced storage for the LeXcheXBadge credential registry
+/// @author MetaLeX Labs, Inc.
+library LeXcheXBadgeStorage {
+ bytes32 constant STORAGE_POSITION = keccak256("metalex.lexchexbadge.storage.v1");
+
+ struct LeXcheXBadgeData {
+ mapping(uint256 => Credential) credentials; // per-token records
+ mapping(bytes32 => CredentialCategory) categories; // category registry
+ bytes32[] categoryIds; // enumeration of registered categories
+ uint256 supply; // next token id / total minted
+ }
+
+ function badgeStorage() internal pure returns (LeXcheXBadgeData storage s) {
+ bytes32 position = STORAGE_POSITION;
+ assembly {
+ s.slot := position
+ }
+ }
+
+ // ── Credentials ──────────────────────────────────────────────────────────
+
+ function getCredential(uint256 tokenId) internal view returns (Credential storage) {
+ return badgeStorage().credentials[tokenId];
+ }
+
+ function setCredential(uint256 tokenId, Credential memory cred) internal {
+ badgeStorage().credentials[tokenId] = cred;
+ }
+
+ function deleteCredential(uint256 tokenId) internal {
+ delete badgeStorage().credentials[tokenId];
+ }
+
+ // ── Categories ───────────────────────────────────────────────────────────
+
+ function getCategory(bytes32 categoryId) internal view returns (CredentialCategory storage) {
+ return badgeStorage().categories[categoryId];
+ }
+
+ function setCategory(bytes32 categoryId, CredentialCategory memory category) internal {
+ LeXcheXBadgeData storage s = badgeStorage();
+ if (!s.categories[categoryId].exists) {
+ s.categoryIds.push(categoryId);
+ }
+ s.categories[categoryId] = category;
+ }
+
+ function getCategoryIds() internal view returns (bytes32[] memory) {
+ return badgeStorage().categoryIds;
+ }
+
+ // ── Supply ───────────────────────────────────────────────────────────────
+
+ function getSupply() internal view returns (uint256) {
+ return badgeStorage().supply;
+ }
+
+ function incrementSupply() internal returns (uint256) {
+ LeXcheXBadgeData storage s = badgeStorage();
+ uint256 current = s.supply;
+ s.supply = current + 1;
+ return current;
+ }
+}
diff --git a/src/interfaces/ICyberAgreementRegistry.sol b/src/interfaces/ICyberAgreementRegistry.sol
index 03b2cdee..18645c83 100644
--- a/src/interfaces/ICyberAgreementRegistry.sol
+++ b/src/interfaces/ICyberAgreementRegistry.sol
@@ -187,4 +187,5 @@ interface ICyberAgreementRegistry {
function isFinalized(bytes32 contractId) external view returns (bool);
function allPartiesFinalized(bytes32 contractId) external view returns (bool);
+
}
diff --git a/src/interfaces/ICyberCertPrinter.sol b/src/interfaces/ICyberCertPrinter.sol
index b7962848..3ff31307 100644
--- a/src/interfaces/ICyberCertPrinter.sol
+++ b/src/interfaces/ICyberCertPrinter.sol
@@ -41,9 +41,55 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
-import "./IIssuanceManager.sol";
import "../CyberCorpConstants.sol";
-import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
+import "openzeppelin-contracts/token/ERC721/IERC721.sol";
+
+struct CertificateDetails {
+ string signingOfficerName;
+ string signingOfficerTitle;
+ uint256 investmentAmountUSD;
+ uint256 issuerUSDValuationAtTimeOfInvestment;
+ uint256 unitsRepresented;
+ string legalDetails;
+ bytes extensionData;
+}
+
+struct Endorsement {
+ address endorser;
+ uint256 timestamp;
+ bytes signatureHash;
+ address registry; //optional
+ bytes32 agreementId; //optional
+ address endorsee;
+ string endorseeName;
+}
+
+struct OwnerDetails {
+ string name;
+ address ownerAddress;
+}
+
+enum RestrictionType {
+ Unspecified,
+ TransferConsentRequired,
+ RestrictedSecurityRule144,
+ UnregisteredSecurities,
+ RegulationS,
+ ContentiousHardfork,
+ Custom
+}
+
+struct RestrictiveLegend {
+ RestrictionType restrictionType;
+ string title;
+ string text;
+ string jurisdiction;
+ bytes32 referenceId;
+ uint64 effectiveTimestamp;
+ uint64 expirationTimestamp;
+ bool active;
+ bytes data;
+}
interface ICyberCertPrinter is IERC721 {
function initialize(
@@ -61,6 +107,7 @@ interface ICyberCertPrinter is IERC721 {
function updateIssuanceManager(address _issuanceManager) external;
function updateDefaultLegend(string[] memory _ledger) external;
function defaultLegend() external view returns (string[] memory);
+ function defaultRestrictiveLegends() external view returns (RestrictiveLegend[] memory);
function setRestrictionHook(uint256 _id, address _hookAddress) external;
function setGlobalRestrictionHook(address hookAddress) external;
function safeMint(
@@ -75,6 +122,13 @@ interface ICyberCertPrinter is IERC721 {
CertificateDetails memory details,
string memory investorName
) external returns (uint256);
+ function safeMintAndAssign(
+ address to, // custodian
+ address owner, // legal owner
+ uint256 tokenId,
+ CertificateDetails memory details,
+ string memory ownerName
+ ) external returns (uint256);
function assignCert(
address from,
uint256 tokenId,
@@ -115,6 +169,14 @@ interface ICyberCertPrinter is IERC721 {
function removeCertLegendAt(uint256 tokenId, uint256 index) external;
function addDefaultLegend(string memory newLegend) external;
function removeDefaultLegendAt(uint256 index) external;
+ function addDefaultRestrictiveLegend(RestrictiveLegend memory newLegend) external;
+ function removeDefaultRestrictiveLegendAt(uint256 index) external;
+ function getDefaultRestrictiveLegendAt(uint256 index) external view returns (RestrictiveLegend memory);
+ function getDefaultRestrictiveLegendCount() external view returns (uint256);
+ function addCertRestrictiveLegend(uint256 tokenId, RestrictiveLegend memory newLegend) external;
+ function removeCertRestrictiveLegendAt(uint256 tokenId, uint256 index) external;
+ function getCertRestrictiveLegendAt(uint256 tokenId, uint256 index) external view returns (RestrictiveLegend memory);
+ function getCertRestrictiveLegendCount(uint256 tokenId) external view returns (uint256);
function getEndorsementHistory(
uint256 tokenId,
uint256 index
@@ -132,9 +194,18 @@ interface ICyberCertPrinter is IERC721 {
);
function tokenURI(uint256 tokenId) external view returns (string memory);
function certificateUri() external view returns (string memory);
+ function holderCount() external view returns (uint256);
function totalSupply() external view returns (uint256);
function tokenByIndex(uint256 index) external view returns (uint256);
function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
+
+ // ERC721-like APIs for legal owner
function legalOwnerOf(uint256 tokenId) external view returns (address);
+ function balanceOfLegalOwner(address owner) external view returns (uint256);
+ function tokenOfLegalOwnerByIndex(address owner, uint256 index) external view returns (uint256);
+
function setTokenTransferable(uint256 tokenId, bool value) external;
+ function increaseUnitsReserved(uint256 tokenId, uint256 amount) external;
+ function decreaseUnitsReserved(uint256 tokenId, uint256 amount) external;
+ function unitsReserved(uint256 tokenId) external view returns (uint256);
}
diff --git a/src/interfaces/ICyberCorp.sol b/src/interfaces/ICyberCorp.sol
index 78fc5235..f42c4b75 100644
--- a/src/interfaces/ICyberCorp.sol
+++ b/src/interfaces/ICyberCorp.sol
@@ -73,6 +73,13 @@ interface ICyberCorp {
function setEscrowedOfficerSignature(uint256 index, bytes calldata signature) external;
function getEscrowedOfficerSignature(uint256 index) external view returns (bytes memory);
function getEscrowedOfficerSignatureCount() external view returns (uint256);
+ function extension() external view returns (address);
+ function extensionType() external view returns (bytes32);
+ function extensionData() external view returns (bytes memory);
+ function setExtension(address _extension, bytes32 _extensionType) external;
+ function setExtensionData(bytes calldata _extensionData) external;
+ function clearExtension() external;
+ function getExtensionURI() external view returns (string memory);
}
diff --git a/src/interfaces/IDealManager.sol b/src/interfaces/IDealManager.sol
index 256da13e..11f31557 100644
--- a/src/interfaces/IDealManager.sol
+++ b/src/interfaces/IDealManager.sol
@@ -42,6 +42,7 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
import "./IIssuanceManager.sol";
+import "./ISecondaryTradeStorage.sol";
interface IDealManager {
function proposeDeal(
@@ -57,7 +58,7 @@ interface IDealManager {
address[] memory conditions,
bytes32 secretHash,
uint256 expiry
- ) external returns (bytes32 agreementId);
+ ) external returns (bytes32 agreementId, uint256[] memory certIds);
function proposeAndSignDeal(
address[] memory _certPrinterAddress,
@@ -125,6 +126,17 @@ interface IDealManager {
address _corp,
address _dealRegistry,
address _issuanceManager,
- address _upgradeFactory
+ address _upgradeFactory
) external;
+
+ // Secondary trade
+ function postOffer(PostOfferParams calldata params) external returns (bytes32 offerAgreementId);
+ function cancelOffer(bytes32 offerAgreementId) external;
+ function acceptOffer(AcceptOfferParams calldata params) external returns (bytes32 settlementAgreementId);
+ function setMinTradeThreshold(uint256 units, uint256 consideration) external;
+ function setSettlementWindow(uint256 window) external;
+ function getSettlementWindow() external view returns (uint256);
+ function setDefaultIntegrator(address integrator) external;
+ function getOffer(bytes32 offerAgreementId) external view returns (Offer memory);
+ function getSecondaryEscrow(bytes32 agreementId) external view returns (SecondaryEscrow memory);
}
diff --git a/src/interfaces/IDealManagerFactory.sol b/src/interfaces/IDealManagerFactory.sol
index 7c7f21b8..3bbcf007 100644
--- a/src/interfaces/IDealManagerFactory.sol
+++ b/src/interfaces/IDealManagerFactory.sol
@@ -50,4 +50,9 @@ interface IDealManagerFactory {
function getDefaultFeeRatio() external view returns (uint256);
function getPlatformPayable() external view returns (address);
+
+ // Integrator whitelist and per-integrator fee split (cyberTRADE spec §12B.4)
+ function isIntegratorWhitelisted(address integrator) external view returns (bool);
+ function getIntegratorFeeShare(address integrator) external view returns (uint256);
+ function setIntegrator(address integrator, bool approved, uint256 feeShare) external;
}
diff --git a/src/interfaces/IDealManagerStorage.sol b/src/interfaces/IDealManagerStorage.sol
new file mode 100644
index 00000000..0ee5ed27
--- /dev/null
+++ b/src/interfaces/IDealManagerStorage.sol
@@ -0,0 +1,76 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+/// @title IDealManagerStorage
+/// @notice Events/errors owned by the DealManagerStorage library — the single source of truth.
+/// @dev DealManagerStorage emits/reverts these via the qualified IDealManagerStorage.X form, and any
+/// manager that links the library (e.g. DealManager) inherits this interface so the selectors/topics
+/// appear in its ABI for off-chain decoding.
+interface IDealManagerStorage {
+ /// @notice Emitted when a new deal is proposed
+ event DealProposed(
+ bytes32 indexed agreementId,
+ address[] certAddress,
+ uint256[] certId,
+ address paymentToken,
+ uint256 paymentAmount,
+ bytes32 templateId,
+ address corp,
+ address dealRegistry,
+ address[] parties,
+ address[] conditions,
+ bool hasSecret
+ );
+ /// @notice Emitted when a deal is finalized
+ event DealFinalized(
+ bytes32 indexed agreementId,
+ address indexed signer,
+ address indexed corp,
+ address dealRegistry,
+ bool fillUnallocated
+ );
+
+ error CounterPartyValueMismatch();
+ error DealNotPending();
+ error DealNotExpired();
+}
diff --git a/src/interfaces/IERC5484.sol b/src/interfaces/IERC5484.sol
new file mode 100644
index 00000000..c3f6a7cf
--- /dev/null
+++ b/src/interfaces/IERC5484.sol
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity 0.8.28;
+
+/// @title IERC5484 - Consensual Soulbound Token interface (EIP-5484)
+/// @notice Canonical declaration for the LeXcheXBadge family. (lexchex.sol / ILexChex.sol carry their
+/// own file-local copies for the legacy v1 deployment; do not import this file alongside those.)
+interface IERC5484 {
+ enum BurnAuth {
+ IssuerOnly,
+ OwnerOnly,
+ Both,
+ Neither
+ }
+
+ event Issued(
+ address indexed from,
+ address indexed to,
+ uint256 indexed tokenId,
+ BurnAuth burnAuth
+ );
+
+ function burnAuth(uint256 tokenId) external view returns (BurnAuth);
+}
diff --git a/src/interfaces/IIssuanceManager.sol b/src/interfaces/IIssuanceManager.sol
index d8e9dd76..be45adf0 100644
--- a/src/interfaces/IIssuanceManager.sol
+++ b/src/interfaces/IIssuanceManager.sol
@@ -224,6 +224,18 @@ interface IIssuanceManager {
bool value
) external;
+ function increaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external;
+
+ function decreaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external;
+
function addDefaultLegend(
address certAddress,
string memory newLegend
@@ -246,6 +258,28 @@ interface IIssuanceManager {
uint256 index
) external;
+ function addDefaultRestrictiveLegend(
+ address certAddress,
+ RestrictiveLegend memory newLegend
+ ) external;
+
+ function removeDefaultRestrictiveLegendAt(
+ address certAddress,
+ uint256 index
+ ) external;
+
+ function addCertRestrictiveLegend(
+ address certAddress,
+ uint256 tokenId,
+ RestrictiveLegend memory newLegend
+ ) external;
+
+ function removeCertRestrictiveLegendAt(
+ address certAddress,
+ uint256 tokenId,
+ uint256 index
+ ) external;
+
function deployCyberScrip(
address certAddress,
ITransferRestrictionHook[] memory typeRestrictionHooks,
@@ -371,5 +405,27 @@ interface IIssuanceManager {
function cyberCertPrinterBeacon() external view returns (UpgradeableBeacon);
function cyberScripBeacon() external view returns (UpgradeableBeacon);
function printers(uint256 index) external view returns (address);
+ function isPrinter(address printer) external view returns (bool);
function setUriBuilder(address _uriBuilder) external;
+
+ /// @notice Single-source signal for the buyer's newly minted Ledger Entry Token at secondary settlement.
+ /// @dev Emitted from the linked storage lib in the IssuanceManager's context; agreementId is the
+ /// settlementAgreementId (joins the DealManager's finalization event) and sellerVoided distinguishes a
+ /// full sale (seller token voided) from a partial (decremented in place).
+ event SecondaryTransferExecuted(
+ bytes32 indexed agreementId,
+ address indexed certPrinter,
+ address indexed buyer,
+ uint256 sellerTokenId,
+ uint256 buyerTokenId,
+ address seller,
+ uint256 units,
+ uint256 sellerUnitsAfter, // 0 when the seller token is voided (full sale)
+ uint256 buyerUnitsAfter, // == units on a fresh mint; existing balance + units on a fold
+ bool sellerVoided,
+ bool buyerTokenIsMinted // indicates whether it's a freshly minted token or folded into an existing one
+ );
+
+ // Secondary trade entry points (cyberTRADE; implementation pending)
+ function secondaryTransfer(bytes calldata dealMetadata) external;
}
\ No newline at end of file
diff --git a/src/interfaces/ILexChexBadge.sol b/src/interfaces/ILexChexBadge.sol
new file mode 100644
index 00000000..66ce78fa
--- /dev/null
+++ b/src/interfaces/ILexChexBadge.sol
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity 0.8.28;
+
+import "./IERC5484.sol";
+import {CategoryKind, Credential, CredentialCategory} from "../creds/storage/lexchexBadgeStorage.sol";
+
+/// @title ILexChexBadge - read/lifecycle interface for the LeXcheXBadge credential registry
+/// @author MetaLeX Labs, Inc.
+/// @notice The credential substrate the cyberTRADE threshold conditions read. One deployment = one
+/// credentialing layer under one operator's BorgAuth (Legion, MetaLeX canonical, or another operator).
+interface ILexChexBadge is IERC5484 {
+ // ── Lifecycle ────────────────────────────────────────────────────────────
+ function mint(address to, bytes32 categoryId, Credential memory cred) external returns (uint256 tokenId);
+ function recertify(uint256 tokenId, Credential memory cred) external;
+ function updateAttributes(uint256 tokenId, bytes2 usState, uint32 beneficialOwnerCount) external;
+ function void(uint256 tokenId, string memory reason) external;
+ function burn(uint256 tokenId) external;
+
+ // ── Category admin ───────────────────────────────────────────────────────
+ function createCategory(bytes32 categoryId, CredentialCategory memory category) external;
+ function updateCategory(bytes32 categoryId, CredentialCategory memory category) external;
+ function retireCategory(bytes32 categoryId) external;
+ function getCategory(bytes32 categoryId) external view returns (CredentialCategory memory);
+ function getCategoryIds() external view returns (bytes32[] memory);
+
+ // ── Validity reads (issued, not voided, not expired) ─────────────────────
+ function isValid(uint256 tokenId) external view returns (bool);
+ function hasValidCredential(address owner, bytes32 categoryId) external view returns (bool);
+ function hasValidCredentialOfKind(address owner, CategoryKind kind, string memory investorTypeFilter) external view returns (bool);
+ function hasValidWhitelistFor(address owner, address spv) external view returns (bool);
+
+ // ── Attribute getters (sourced from the most recent valid credential carrying the attribute) ──
+ function getUsState(address owner) external view returns (bytes2);
+ function getBeneficialOwnerCount(address owner) external view returns (uint32);
+ function getInvestorJurisdiction(address owner) external view returns (string memory);
+
+ /// @notice Seasoning reference (§11.1B): earliest valid issuance of the given kind; 0 when none.
+ function earliestValidIssuance(address owner, CategoryKind kind) external view returns (uint64);
+
+ // ── Carried over from LeXcheX v1 ─────────────────────────────────────────
+ function hasValidLexCheX(address owner) external view returns (bool);
+ function getTokenIdsByOwner(address owner) external view returns (uint256[] memory);
+ function getCredentialByOwner(address owner) external view returns (uint256 tokenId);
+ function getCredential(uint256 tokenId) external view returns (Credential memory);
+ function balanceOf(address owner) external view returns (uint256);
+}
diff --git a/src/interfaces/ILexScrowStorage.sol b/src/interfaces/ILexScrowStorage.sol
new file mode 100644
index 00000000..99a3d1cb
--- /dev/null
+++ b/src/interfaces/ILexScrowStorage.sol
@@ -0,0 +1,66 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import {Escrow} from "../storage/LexScrowStorage.sol";
+
+/// @title ILexScrowStorage
+/// @notice In addition to the common interfaces,
+/// managers who use LexScrowStorage are expected to provide their own fee logic through the fee hooks below.
+/// Any manager that links the library (e.g. DealManager) inherits this interface so the selectors/topics appear in its ABI for off-chain decoding.
+interface ILexScrowStorage {
+ /// @notice Attached escrow conditions were not all satisfied. Shared across managers that link
+ /// LexScrowStorage (e.g. DealManager, RoundManager) so they expose the same selector.
+ error AgreementConditionsNotMet();
+
+ /// @notice Get escrow details for a given agreement id
+ function getEscrowDetails(bytes32 agreementId) external view returns (Escrow memory);
+
+ /// @notice Check all conditions attached to the escrow for the given agreement
+ function conditionCheck(bytes32 agreementId) external view returns (bool);
+
+ /// @notice Compute fee based on ticket size
+ function computeFee(uint256 size) external view returns (uint256);
+
+ /// @notice Get the payable address for the fees
+ function getPlatformPayable() external view returns (address);
+}
diff --git a/src/interfaces/ISecondaryTradeStorage.sol b/src/interfaces/ISecondaryTradeStorage.sol
new file mode 100644
index 00000000..59c0844b
--- /dev/null
+++ b/src/interfaces/ISecondaryTradeStorage.sol
@@ -0,0 +1,245 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+enum OfferSide { SELL, BUY }
+
+enum OfferStatus { LIVE, CANCELLED, PARTIALLY_ACCEPTED, FULLY_ACCEPTED, FINALIZED }
+
+enum SecondaryEscrowStatus { ACCEPTED, FINALIZED, VOIDED }
+
+enum ExemptionPathway { RULE_144, SECTION_4A7, SECTION_4A1HALF, RULE_144A, REGULATION_S }
+
+enum HostingMode { DIRECT, ADMINISTERED }
+
+struct Offer {
+ address spvAddress; // cyberCORP address this offer belongs to
+ address offeror;
+ OfferSide side;
+ address certPrinter; // both sides: required; identifies the security class/series
+ uint256 tokenId; // sell offer-only: seller's Ledger Entry Token id; zero for bids
+ uint256 units; // total units offered: immutable once offer is created
+ address paymentToken;
+ uint256 consideration; // total payment for all offered units
+ ExemptionPathway exemptionPathway;
+ uint256 validUntil;
+ bytes counterpartyRestrictions; // spec §8.1 Counterparty restrictions
+ bytes additionalTerms; // spec §8.1 Supplemental fields
+ address integrator;
+ OfferStatus status;
+ uint256 unitsAccepted; // units committed to active and finalized settlements; decrements on void only
+ uint256 paymentAccepted; // consideration committed to active and finalized settlements; decrements on void only
+ uint256 unitsFinalized; // units consumed by finalized settlements; monotonic (finalized lots never void), but may lag behind `unitsAccepted`
+ bytes32 offerId; // DealManager-generated offer key; NOT a CyberAgreementRegistry record
+ bytes32 templateId; // agreement template id; stored for use at acceptOffer
+ uint256 salt; // offeror-supplied salt; used to derive unique settlementSalt per acceptance
+ string[] globalValues; // agreement global values; stored for use at acceptOffer
+ string[] offerorPartyValues; // offeror's party values; stored for use at acceptOffer
+ bytes offerorAgreementSig; // offeror's EIP-712 sig over offerAgreementId+terms; verified at postOffer, passed to signContractWithEscrow at acceptOffer
+ bytes openEndorsementSig; // sell offer-only: seller's pre-signed open endorsement (spec §7.3.1); in contrast, buy offer's open endorsement is acquired at acceptance and stored in SecondaryEscrow
+ string buyerName; // buy offer-only: buyer's registered name for OwnerDetails; empty for sell offers
+ HostingMode buyerHostingMode; // buy offer-only: Direct or Administered; defaults to Direct for sell offers
+ address adminMultisig; // buy offer-only: delivery address for Administered hosting; zero for sell offers
+ bytes32[] settlementAgreementIds; // appended at each acceptOffer; length == 0 at postOffer (no buyer known yet)
+ address[] thresholdConditions; // resolved from DealManager config at postOffer; re-evaluated at acceptOffer and at finalize
+ address[] closingConditions; // snapshotted from DealManager config at postOffer; evaluated at finalize (gates asset transfer)
+}
+
+// Per-settlement escrow for secondary trades, keyed by settlementAgreementId.
+struct SecondaryEscrow {
+ // custody + lifecycle
+ address counterparty; // acceptor (msg.sender of acceptOffer); buyer/seller derived from offer.side
+ address paymentToken; // ERC20 payment token
+ uint256 paymentAmount; // consideration for this settlement lot
+ uint256 units; // units in this settlement lot
+ uint256 expiry; // settlement deadline
+ SecondaryEscrowStatus status; // ACCEPTED | FINALIZED | VOIDED
+ // secondary-specific routing
+ address feeDestination; // integrator address for fee split; zero = all fees to MetaLeX
+ bytes32 offerId; // back-link to Offer
+ uint256 tokenId; // seller's Ledger Entry Token id; reservation target for decreaseUnitsReserved on void
+ string buyerName; // redundant for buy offer, it would be the same as its counterpart in `Offer`, but we still keep a record here for simplicity
+ HostingMode buyerHostingMode; // redundant for buy offer, it would be the same as its counterpart in `Offer`, but we still keep a record here for simplicity
+ address adminMultisig; // redundant for buy offer, it would be the same as its counterpart in `Offer`, but we still keep a record here for simplicity
+ bytes openEndorsementSig; // redundant for sell offer, it would be the same as its counterpart in `Offer`, but we still keep a record here for simplicity
+}
+
+struct PostOfferParams {
+ OfferSide side;
+ address certPrinter; // sell offers: seller's cert printer; bids: required security class/series filter
+ uint256 tokenId; // sell offer-only: seller's Ledger Entry Token id; zero for bids
+ uint256 units;
+ address paymentToken;
+ uint256 consideration;
+ ExemptionPathway exemptionPathway;
+ uint256 validUntil;
+ bytes counterpartyRestrictions;
+ bytes additionalTerms;
+ address integrator; // zero = use DealManager defaultIntegrator
+ bytes32 templateId;
+ uint256 salt;
+ string[] globalValues;
+ string[] offerorPartyValues;
+ bytes offerorAgreementSig;
+ bytes openEndorsementSig; // sell offer-only
+ string buyerName; // buy offer-only: buyer's registered name for OwnerDetails; empty for sell offers
+ HostingMode buyerHostingMode; // buy offer-only: Direct or Administered; defaults to Direct for sell offers
+ address adminMultisig; // buy offer-only: delivery address for Administered hosting; zero for sell offers
+}
+
+struct AcceptOfferParams {
+ bytes32 offerId;
+ uint256 units;
+ string buyerName; // sell offer-only: ignored for buy offer acceptances (read from Offer instead)
+ HostingMode buyerHostingMode; // sell offer-only: Direct or Administered; ignored for buy offer acceptances
+ address adminMultisig; // sell offer-only: delivery address for Administered hosting; ignored for buy offer acceptances
+ uint256 sellerTokenId; // buy offer-only: seller's token id for bid acceptances; use offer.tokenId for sell offers
+ string[] acceptorPartyValues;
+ bytes acceptorAgreementSig;
+ bytes openEndorsementSig; // buy offer-only: for bid acceptances
+}
+
+/// @title ISecondaryTradeStorage
+/// @notice Events/errors owned by the SecondaryTradeStorage library — the single source of truth.
+/// @dev SecondaryTradeStorage emits/reverts these via the qualified ISecondaryTradeStorage.X form, and
+/// any manager that links the library (e.g. DealManager) inherits this interface so the selectors/topics
+/// appear in its ABI for off-chain decoding.
+interface ISecondaryTradeStorage {
+ // Events below carry every field an off-chain indexer needs to reconstruct secondary-trade status
+ // (order book + settlements) from logs alone: an Offer is fully described by OfferPosted, each settlement
+ // by OfferAccepted (which also reports its funding), and lifecycle transitions by the remaining events.
+ /// @dev offerId/offeror/certPrinter indexed so a UI can filter the order book by security and by user.
+ event OfferPosted(
+ bytes32 indexed offerId,
+ address indexed offeror,
+ address indexed certPrinter,
+ address spvAddress,
+ OfferSide side,
+ uint256 tokenId,
+ uint256 units,
+ address paymentToken,
+ uint256 consideration,
+ ExemptionPathway exemptionPathway,
+ uint256 validUntil,
+ address integrator,
+ bytes32 templateId,
+ string buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig,
+ bytes counterpartyRestrictions,
+ address[] thresholdConditions,
+ address[] closingConditions
+ );
+ event OfferCancelled(bytes32 indexed offerId, address indexed offeror);
+ event OfferAccepted(
+ bytes32 indexed offerId,
+ bytes32 indexed settlementAgreementId,
+ address indexed acceptor,
+ uint256 units,
+ address paymentToken,
+ uint256 paymentAmount,
+ uint256 tokenId,
+ uint256 agreementExpiry,
+ // per-settlement materialization fields, mirroring SecondaryEscrow (sourced from the offer or the
+ // acceptance per side); feeDestination is omitted as it equals the offer's integrator (see OfferPosted).
+ string buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig,
+ bytes openEndorsementSig
+ );
+ event SecondaryTradeAgreementFinalized(bytes32 indexed agreementId, address seller, address buyer, uint256 units, uint256 consideration);
+ event SecondaryTradeAgreementVoided(bytes32 indexed agreementId);
+ /// @dev Reports the realized fee split: feeDestination is the credited integrator (zero when the fee
+ /// routed entirely to the platform). The split is read from live factory state at settlement, so it is
+ /// captured here rather than left for the indexer to recompute.
+ /// @param fee Total fee taken from the trade consideration (paymentAmount * defaultFeeRatio).
+ /// Always equals integratorFee + platformFee; the two split fields are its breakdown, not additions to it.
+ /// @param integratorFee Portion of `fee` credited to feeDestination, fee * integratorFeeShare(feeDestination)
+ /// (0 if no whitelisted integrator).
+ /// @param platformFee Remainder of `fee` routed to the platform (fee - integratorFee).
+ event SecondaryFeeDistributed(
+ bytes32 indexed agreementId,
+ address indexed feeToken,
+ address indexed feeDestination,
+ uint256 fee,
+ uint256 integratorFee,
+ uint256 platformFee
+ );
+
+ error OfferNotAvailable();
+ error OfferExpired();
+ /// @notice A trade's units or consideration is below the admin-set minimum-ticket threshold;
+ /// enforced on the whole offer at postOffer and on each lot at acceptOffer
+ error BelowMinTradeThreshold();
+ error IntegratorNotWhitelisted();
+ error UnitsExceedOffer();
+ error NotOfferor();
+ error NotCertOwner();
+ error MissingCertPrinter();
+ error UnknownCertPrinter();
+ error NotPartyToAgreement();
+ error OfferAlreadyExists();
+ /// @notice Caller is not the signer they claim to be (signer must equal msg.sender)
+ error NotSigner();
+ /// @notice A relayer overload's EIP-712 authorization did not recover to `forAddr`
+ error InvalidSecondaryAuthSignature();
+ /// @notice A relayer overload's unordered authorization nonce was already consumed (replay)
+ error SecondaryAuthReplayed();
+ error SecondaryEscrowNotFound();
+ error SecondaryTradeAgreementAlreadyFinalized();
+ error SecondaryTradeAgreementAlreadyVoided();
+ error SecondaryTradeAgreementExpired();
+ error SecondaryTradeAgreementNotExpired();
+ error SecondaryTradeAgreementNotVoided();
+ /// @notice At finalize, the cert's current legal owner no longer matches the settlement's seller of record
+ /// (ownership moved after listing/acceptance), so the payee and the party whose units are consumed diverge
+ error SecondaryTradeSellerOwnershipChanged();
+ error SecondaryConditionsNotMet(address condition);
+ /// @notice Condition address supplied to a config setter is the zero address
+ error InvalidSecondaryCondition();
+ /// @notice Condition does not advertise ISecondaryTradingCondition via ERC-165 supportsInterface
+ error SecondaryConditionInterfaceUnsupported(address condition);
+ /// @notice Condition is already present in the target list (sets are deduplicated)
+ error SecondaryConditionAlreadyExists();
+ /// @notice removeConditionAt index is past the end of the target list
+ error SecondaryConditionIndexOutOfBounds();
+}
diff --git a/src/interfaces/IUriBuilder.sol b/src/interfaces/IUriBuilder.sol
index b693ef72..6d003545 100644
--- a/src/interfaces/IUriBuilder.sol
+++ b/src/interfaces/IUriBuilder.sol
@@ -42,7 +42,12 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
import "../CyberCorpConstants.sol";
-import {CertificateDetails, Endorsement, OwnerDetails} from "../storage/CyberCertPrinterStorage.sol";
+import {
+ CertificateDetails,
+ Endorsement,
+ OwnerDetails,
+ RestrictiveLegend
+} from "./ICyberCertPrinter.sol";
interface IUriBuilder {
function buildCertificateUri(
@@ -64,6 +69,25 @@ interface IUriBuilder {
address extension
) external view returns (string memory);
+ function buildCertificateUri(
+ string memory cyberCORPName,
+ string memory cyberCORPType,
+ string memory cyberCORPJurisdiction,
+ string memory cyberCORPContactDetails,
+ SecurityClass securityType,
+ SecuritySeries securitySeries,
+ string memory certificateUri,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory details,
+ Endorsement[] memory endorsements,
+ OwnerDetails memory owner,
+ address registry,
+ bytes32 agreementId,
+ uint256 tokenId,
+ address contractAddress,
+ address extension
+ ) external view returns (string memory);
+
function buildCertificateUriNotEncoded(
string memory cyberCORPName,
string memory cyberCORPType,
@@ -82,4 +106,23 @@ interface IUriBuilder {
address contractAddress,
address extension
) external view returns (string memory);
+
+ function buildCertificateUriNotEncoded(
+ string memory cyberCORPName,
+ string memory cyberCORPType,
+ string memory cyberCORPJurisdiction,
+ string memory cyberCORPContactDetails,
+ SecurityClass securityType,
+ SecuritySeries securitySeries,
+ string memory certificateUri,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory details,
+ Endorsement[] memory endorsements,
+ OwnerDetails memory owner,
+ address registry,
+ bytes32 agreementId,
+ uint256 tokenId,
+ address contractAddress,
+ address extension
+ ) external view returns (string memory);
}
diff --git a/src/libs/LexScroWLite.sol b/src/libs/LexScroWLite.sol
deleted file mode 100644
index 1fa5dc54..00000000
--- a/src/libs/LexScroWLite.sol
+++ /dev/null
@@ -1,317 +0,0 @@
-/* .o.
- .888.
- .8"888.
- .8' `888.
- .88ooo8888.
- .8' `888.
-o88o o8888o
-
-
-
-ooo ooooo . ooooo ooooooo ooooo
-`88. .888' .o8 `888' `8888 d8'
- 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
- 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
- 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
- 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
-o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
-
-
-
- .oooooo. .o8 .oooooo.
- d8P' `Y8b "888 d8P' `Y8b
-888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
-888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
-888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
-`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
- `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
- .o..P' 888
- `Y8P' o888o
-_______________________________________________________________________________________________________
-
-All software, documentation and other files and information in this repository (collectively, the "Software")
-are copyright MetaLeX Labs, Inc., a Delaware corporation.
-
-All rights reserved.
-
-The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
-distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
-mechanical, including photocopying, recording, or by any information storage and retrieval system,
-except with the express prior written permission of the copyright holder.*/
-
-pragma solidity ^0.8.20;
-
-import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
-import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
-import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
-import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
-import "../interfaces/ICyberCorp.sol";
-import "../interfaces/ICyberAgreementRegistry.sol";
-import "../interfaces/ICyberCertPrinter.sol";
-import "../interfaces/ICondition.sol";
-import {LexScrowStorage, Escrow, Token, TokenType, EscrowStatus} from "../storage/LexScrowStorage.sol";
-
-
-abstract contract LexScroWLite is Initializable {
- using LexScrowStorage for LexScrowStorage.LexScrowData;
- using SafeERC20 for IERC20;
-
- error DealExpired();
- error EscrowNotPending();
- error EscrowNotPaid();
- error CounterPartyNotSet();
- error DealNotFullySigned();
- error DealNotFinalized();
- error DealAlreadyFinalized();
- error DealNotVoided();
- error DealNotPaid();
- error DealVoided();
-
- event DealVoidedAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
- event DealPaidAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
- event DealFinalizedAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
- event FeeDistributed(bytes32 indexed agreementId, address indexed feeToken, uint256 totalFe);
-
- constructor() {
- }
-
- /// @notice Initialize core addresses for the escrow subsystem
- /// @param _corp Address of the `ICyberCorp` implementation
- /// @param _dealRegistry Address of the `ICyberAgreementRegistry` implementation
- function __LexScroWLite_init(address _corp, address _dealRegistry) internal onlyInitializing {
- LexScrowStorage.setCorp(_corp);
- LexScrowStorage.setDealRegistry(_dealRegistry);
- }
-
- /// @notice Create a new escrow record for an agreement
- /// @param agreementId Unique identifier of the agreement
- /// @param counterParty Counterparty/buyer address
- /// @param corpAssets Assets the company will deliver upon finalization
- /// @param buyerAssets Assets the counterparty will deliver into escrow
- /// @param expiry Unix timestamp after which the deal is considered expired
- function createEscrow(bytes32 agreementId, address counterParty, Token[] memory corpAssets, Token[] memory buyerAssets, uint256 expiry) internal {
- bytes memory blankSignature = abi.encodePacked(bytes32(0));
- Escrow memory newEscrow = Escrow({
- agreementId: agreementId,
- counterParty: counterParty,
- corpAssets: corpAssets,
- buyerAssets: buyerAssets,
- signature: blankSignature,
- expiry: expiry,
- status: EscrowStatus.PENDING
- });
- LexScrowStorage.setEscrow(agreementId, newEscrow);
- }
-
- /// @notice Update escrow counterparty and add endorsement to corp ERC721 certificates
- /// @param agreementId Unique identifier of the agreement
- /// @param counterParty Counterparty/buyer address to set
- /// @param buyerName Human-readable buyer name stored in endorsements
- function updateEscrow(bytes32 agreementId, address counterParty, string memory buyerName) internal {
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
- escrow.counterParty = counterParty;
-
- Endorsement memory newEndorsement = Endorsement(
- address(this),
- block.timestamp,
- escrow.signature,
- LexScrowStorage.getDealRegistry(),
- agreementId,
- escrow.counterParty,
- buyerName
- );
- for(uint256 i = 0; i < escrow.corpAssets.length; i++) {
- if(escrow.corpAssets[i].tokenType == TokenType.ERC721) {
- ICyberCertPrinter(escrow.corpAssets[i].tokenAddress).addEndorsement(escrow.corpAssets[i].tokenId, newEndorsement);
- // check if there is an escrowed officer signature in cybercorp
- bytes memory officerSignature = "";
- address corp = LexScrowStorage.getCorp();
- try ICyberCorp(corp).getEscrowedOfficerSignatureCount() returns (
- uint256 count
- ) {
- if (count > 0) {
- try ICyberCorp(corp).getEscrowedOfficerSignature(0) returns (bytes memory sig) {
- officerSignature = sig;
- } catch {}
- }
- } catch {}
- if (officerSignature.length > 0) {
- ICyberCertPrinter(escrow.corpAssets[i].tokenAddress).addIssuerSignature(
- escrow.corpAssets[i].tokenId,
- officerSignature
- );
- }
- }
- }
- }
-
- /// @notice Pull buyer assets into escrow and mark the escrow as PAID
- /// @param agreementId Unique identifier of the agreement
- function handleCounterPartyPayment(bytes32 agreementId) internal {
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
- if(escrow.status != EscrowStatus.PENDING) revert EscrowNotPending();
- if(escrow.counterParty == address(0)) revert CounterPartyNotSet();
-
- for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
- if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
- IERC20(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].amount);
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
- IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].tokenId);
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
- IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
- }
- }
-
- emit DealPaidAt(agreementId, LexScrowStorage.getDealRegistry(), block.timestamp);
- escrow.status = EscrowStatus.PAID;
- }
-
- /// @notice Void a PAID escrow and refund all buyer assets
- /// @dev External callers should implement reentrancy guards
- /// @param agreementId Unique identifier of the agreement
- function voidAndRefund(bytes32 agreementId) internal {
- // Check: check status
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
- if(escrow.status != EscrowStatus.PAID) revert EscrowNotPaid();
- if(!ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId)) revert DealNotVoided();
-
- // Effect: update status
- voidEscrow(agreementId);
-
- // Interaction: Refund buyer assets
- for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
- if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
- IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(escrow.counterParty, escrow.buyerAssets[i].amount);
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
- IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.buyerAssets[i].tokenId);
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
- IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
- }
- }
- }
-
- /// @notice Finalize a PAID escrow, transferring assets and distributing any fees
- /// @dev External callers should implement reentrancy guards
- /// @param agreementId Unique identifier of the agreement
- function finalizeEscrow(bytes32 agreementId) internal {
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
-
- // Check: Check all conditions before proceeding
- if(block.timestamp > escrow.expiry) revert DealExpired();
- if(escrow.status != EscrowStatus.PAID) revert EscrowNotPaid();
-
- // Effect: Update state before external calls
- escrow.status = EscrowStatus.FINALIZED;
- emit DealFinalizedAt(agreementId, LexScrowStorage.getDealRegistry(), block.timestamp);
-
- // Interaction: Transfer buyer assets to company and collect fees
- for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
- if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
- uint256 amountToCompany = escrow.buyerAssets[i].amount;
- uint256 fee = 0;
-
- // Check: if the asset is fee token
- if (escrow.buyerAssets[i].isFee) {
- // Effect: Calculate fees
- fee = computeFee(escrow.buyerAssets[i].amount);
- amountToCompany -= fee;
-
- emit FeeDistributed(agreementId, escrow.buyerAssets[i].tokenAddress, fee);
- }
-
- // Interaction: Distribute payment and fees
- if (amountToCompany > 0) {
- IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(ICyberCorp(LexScrowStorage.getCorp()).companyPayable(), amountToCompany);
- }
- if (fee > 0) {
- IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(getPlatformPayable(), fee);
- }
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
- IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), ICyberCorp(LexScrowStorage.getCorp()).companyPayable(), escrow.buyerAssets[i].tokenId);
- }
- else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
- IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), ICyberCorp(LexScrowStorage.getCorp()).companyPayable(), escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
- }
- }
-
- // Interaction: Transfer corp assets to counter party
- for(uint256 i = 0; i < escrow.corpAssets.length; i++) {
- if(escrow.corpAssets[i].tokenType == TokenType.ERC20) {
- IERC20(escrow.corpAssets[i].tokenAddress).safeTransfer(escrow.counterParty, escrow.corpAssets[i].amount);
- }
- else if(escrow.corpAssets[i].tokenType == TokenType.ERC721) {
- IERC721(escrow.corpAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.corpAssets[i].tokenId);
- }
- else if(escrow.corpAssets[i].tokenType == TokenType.ERC1155) {
- IERC1155(escrow.corpAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.corpAssets[i].tokenId, escrow.corpAssets[i].amount, "");
- }
- }
- }
-
- /// @notice Check all conditions attached to the escrow for the given agreement
- /// @param agreementId Unique identifier of the agreement
- /// @return True if all conditions pass, false otherwise
- function conditionCheck(bytes32 agreementId) public view returns (bool) {
- ICondition[] storage conditions = LexScrowStorage.getConditionsByEscrow(agreementId);
- //convert bytes32 to bytes
- bytes memory agreementIdBytes = abi.encodePacked(agreementId);
-
- for(uint256 i = 0; i < conditions.length; i++) {
- if(!ICondition(conditions[i]).checkCondition(address(this), msg.sig, agreementIdBytes))
- return false;
- }
- return true;
- }
-
- /// @notice Mark an escrow as VOIDED and emit an event
- /// @param agreementId Unique identifier of the agreement
- function voidEscrow(bytes32 agreementId) internal {
- Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
- escrow.status = EscrowStatus.VOIDED;
- emit DealVoidedAt(agreementId, LexScrowStorage.getDealRegistry(), block.timestamp);
- }
-
- /// @notice Get escrow details for a given agreement id
- /// @param agreementId Unique identifier of the agreement
- /// @return Escrow struct containing current state
- function getEscrowDetails(bytes32 agreementId) public view returns (Escrow memory) {
- return LexScrowStorage.getEscrow(agreementId);
- }
-
- /// @notice Compute fee based on ticket size
- /// @dev Child contract should implement the actual logic
- /// @return Fee amount
- function computeFee(uint256 size) public virtual view returns (uint256);
-
- /// @notice Get the payable address for the fees
- /// @dev Child contract should implement the actual logic
- /// @return Payable address for the fees
- function getPlatformPayable() public virtual view returns (address);
-
- /// @notice ERC721 receiver hook for safe transfers into escrow
- /// @param operator Address which initiated the transfer
- /// @param from Previous owner of the token
- /// @param tokenId Identifier of the token being transferred
- /// @param data Additional data with no specified format
- /// @return Selector to confirm the token transfer
- function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4) {
- return this.onERC721Received.selector;
- }
-
- /// @notice ERC1155 receiver hook for safe transfers into escrow
- /// @param operator Address which initiated the transfer
- /// @param from Previous owner of the token(s)
- /// @param tokenId Identifier of the token being transferred
- /// @param amount Amount of tokens being transferred
- /// @param data Additional data with no specified format
- /// @return Selector to confirm the token transfer
- function onERC1155Received(address operator, address from, uint256 tokenId, uint256 amount, bytes calldata data) external returns (bytes4) {
- return this.onERC1155Received.selector;
- }
-}
diff --git a/src/libs/conditions/BaseSecondaryTradingCondition.sol b/src/libs/conditions/BaseSecondaryTradingCondition.sol
new file mode 100644
index 00000000..dcd7108a
--- /dev/null
+++ b/src/libs/conditions/BaseSecondaryTradingCondition.sol
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "openzeppelin-contracts/interfaces/IERC165.sol";
+import "../../interfaces/IDealManager.sol";
+
+/// @title ISecondaryTradingCondition - Strongly-typed condition interface for the secondary-trading path
+/// @author MetaLeX Labs, Inc.
+/// @notice Unlike the generic ICondition (opaque `bytes` payload requiring abi.decode), the caller hands the
+/// condition the DealManager and the two ids it needs, so implementations resolve offer/escrow state through
+/// IDealManager.getOffer / getSecondaryEscrow with compile-time-checked arguments.
+interface ISecondaryTradingCondition is IERC165 {
+ /// @param dealManager DealManager evaluating the condition; source of offer/escrow state
+ /// @param functionSignature msg.sig of the gated DealManager entrypoint (post/accept/finalize).
+ /// Note since most of DealManager's functions are from external libraries,
+ /// msg.sig will reflect external library's instead of DealManager's.
+ /// Moreover, the input argument struct's name will be used literally instead of the underlying tuples
+ /// for computing an external library's function (ex. bytes4(keccak256("postOffer(PostOfferParams)"))
+ /// @param offerId offer key under evaluation
+ /// @param agreementId settlement agreement id; bytes32(0) at postOffer (no settlement yet)
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4 functionSignature,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view returns (bool);
+}
+
+/// @title BaseSecondaryTradingCondition - Base for secondary-trading conditions
+/// @author MetaLeX Labs, Inc.
+/// @notice Specialized counterpart of BaseCondition for the typed ISecondaryTradingCondition family, letting
+/// SecondaryTradeStorage (1) validate an attached condition via ERC-165 supportsInterface before wiring it in,
+/// and (2) call checkCondition with typed arguments instead of an abi-encoded blob.
+abstract contract BaseSecondaryTradingCondition is ISecondaryTradingCondition {
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4 functionSignature,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view virtual returns (bool);
+
+ function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) {
+ return interfaceId == type(ISecondaryTradingCondition).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/src/libs/conditions/NonUSNationalityCondition.sol b/src/libs/conditions/NonUSNationalityCondition.sol
index 7eaa78a9..601642ac 100644
--- a/src/libs/conditions/NonUSNationalityCondition.sol
+++ b/src/libs/conditions/NonUSNationalityCondition.sol
@@ -5,7 +5,7 @@ import "openzeppelin-contracts/interfaces/IERC165.sol";
import "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol";
import "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "./baseCondition.sol";
-import "../LexScroWLite.sol";
+import {ILexScrowStorage} from "../../interfaces/ILexScrowStorage.sol";
import "../auth.sol";
import "../../interfaces/IZKPassportVerifier.sol";
@@ -169,13 +169,13 @@ contract NonUSNationalityCondition is BaseCondition, UUPSUpgradeable, BorgAuthAC
emit FounderOverrideUpdated(_manager, _investor, _approved, msg.sender);
}
- /// @notice Condition check used by LexScroWLite.conditionCheck
+ /// @notice Condition check used by LexScrowStorage.conditionCheck
function checkCondition(
address _contract,
bytes4,
bytes memory data
) public view override returns (bool) {
- LexScroWLite lexScrow = LexScroWLite(_contract);
+ ILexScrowStorage lexScrow = ILexScrowStorage(_contract);
bytes32 agreementId = abi.decode(data, (bytes32));
address counterparty = lexScrow.getEscrowDetails(agreementId).counterParty;
// check overrides first, then the ZK proof
diff --git a/src/libs/conditions/baseCondition.sol b/src/libs/conditions/baseCondition.sol
index f30a5eb6..48d4dfa0 100644
--- a/src/libs/conditions/baseCondition.sol
+++ b/src/libs/conditions/baseCondition.sol
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.28;
-import "@openzeppelin/contracts/interfaces/IERC165.sol";
+import "openzeppelin-contracts/interfaces/IERC165.sol";
import "../../interfaces/ICondition.sol";
/// @title BaseCondition - A contract that defines the interface for conditions
diff --git a/src/libs/conditions/lexchexCondition.sol b/src/libs/conditions/lexchexCondition.sol
index 7d56d80b..dd4369c5 100644
--- a/src/libs/conditions/lexchexCondition.sol
+++ b/src/libs/conditions/lexchexCondition.sol
@@ -5,7 +5,7 @@ import "@openzeppelin/contracts/interfaces/IERC165.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "./baseCondition.sol";
import "../../interfaces/ILexChex.sol";
-import "../LexScroWLite.sol";
+import {ILexScrowStorage} from "../../interfaces/ILexScrowStorage.sol";
import "../auth.sol";
/// @title LexChexCondition - A condition that checks if the user has a valid LexChex accreditation
@@ -30,9 +30,9 @@ contract LexChexCondition is BaseCondition, BorgAuthACL {
}
function checkCondition(address _contract, bytes4 _functionSignature, bytes memory data) public view override returns (bool) {
- LexScroWLite lexScrow = LexScroWLite(_contract);
+ ILexScrowStorage lexScrow = ILexScrowStorage(_contract);
bytes32 agreementId = abi.decode(data, (bytes32));
-
+
// Get the counterparty address directly from escrow details
address counterparty = lexScrow.getEscrowDetails(agreementId).counterParty;
return ILexChex(lexchex).hasValidLexCheX(counterparty);
diff --git a/src/libs/conditions/secondary/AgreementSignedCondition.sol b/src/libs/conditions/secondary/AgreementSignedCondition.sol
new file mode 100644
index 00000000..d04e445d
--- /dev/null
+++ b/src/libs/conditions/secondary/AgreementSignedCondition.sol
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ICyberAgreementRegistry.sol";
+
+/// @title AgreementSignedCondition - both parties' signatures recorded on the trade agreement
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition. True only once every party's signature is recorded on the
+/// settlement agreement in the CyberAgreementRegistry. Trivially satisfied at the acceptance transaction
+/// itself (acceptOffer creates the settlement fully signed); useful as a defensive invariant for any
+/// flow that composes conditions independently. Silent at posting — no settlement agreement exists yet.
+contract AgreementSignedCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidRegistry();
+
+ event RegistryUpdated(address registry);
+
+ ICyberAgreementRegistry public registry;
+
+ uint256[49] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, address _registry) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_registry == address(0)) revert InvalidRegistry();
+ registry = ICyberAgreementRegistry(_registry);
+ emit RegistryUpdated(_registry);
+ }
+
+ function updateRegistry(address _registry) external onlyAdmin {
+ if (_registry == address(0)) revert InvalidRegistry();
+ registry = ICyberAgreementRegistry(_registry);
+ emit RegistryUpdated(_registry);
+ }
+
+ function checkCondition(
+ IDealManager,
+ bytes4,
+ bytes32,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ // Posting context: the settlement agreement does not exist yet
+ if (agreementId == bytes32(0)) return true;
+ return registry.allPartiesSigned(agreementId);
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/CFIUSCondition.sol b/src/libs/conditions/secondary/CFIUSCondition.sol
new file mode 100644
index 00000000..f12073ea
--- /dev/null
+++ b/src/libs/conditions/secondary/CFIUSCondition.sol
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {CategoryKind} from "../../../creds/storage/lexchexBadgeStorage.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title CFIUSCondition - FIRRMA gating for CFIUS-sensitive SPVs
+/// @author MetaLeX Labs, Inc.
+/// @notice Per-SPV deployment, and only for SPVs that do not satisfy the FIRRMA investment fund
+/// exception (31 CFR §800.307) — most SPVs never deploy it. Blocks transfers to non-U.S. persons or
+/// persons from blocked jurisdictions pending manual GP review and a CFIUS clearance attestation
+/// recorded here by the SPV's admin.
+contract CFIUSCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidBadge();
+ error InvalidBuyer();
+
+ event BadgeUpdated(address badge);
+ event TidUsBusinessUpdated(bool tidUsBusiness);
+ event BlockedJurisdictionsUpdated(string[] jurisdictions);
+ event CfiusClearanceUpdated(address indexed buyer, bool cleared, address indexed approver);
+
+ ILexChexBadge public badge;
+ /// @notice The SPV's CFIUS sensitivity flag: TID U.S. business determination. When false the
+ /// condition is dormant (always passes).
+ bool public tidUsBusiness;
+ /// @notice Jurisdictions (country codes matching badge credential jurisdiction strings) that always
+ /// require clearance regardless of non-U.S.-person analysis
+ string[] public blockedJurisdictions;
+
+ /// @notice GP-recorded CFIUS clearance attestations, per buyer
+ mapping(address => bool) public cfiusCleared;
+
+ uint256[45] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(
+ address _auth,
+ address _badge,
+ bool _tidUsBusiness,
+ string[] memory _blockedJurisdictions
+ ) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ tidUsBusiness = _tidUsBusiness;
+ blockedJurisdictions = _blockedJurisdictions;
+ emit BadgeUpdated(_badge);
+ emit TidUsBusinessUpdated(_tidUsBusiness);
+ emit BlockedJurisdictionsUpdated(_blockedJurisdictions);
+ }
+
+ function updateBadge(address _badge) external onlyAdmin {
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function setTidUsBusiness(bool _tidUsBusiness) external onlyAdmin {
+ tidUsBusiness = _tidUsBusiness;
+ emit TidUsBusinessUpdated(_tidUsBusiness);
+ }
+
+ function setBlockedJurisdictions(string[] memory _blockedJurisdictions) external onlyAdmin {
+ blockedJurisdictions = _blockedJurisdictions;
+ emit BlockedJurisdictionsUpdated(_blockedJurisdictions);
+ }
+
+ /// @notice Records the outcome of the GP's manual review / CFIUS clearance for a buyer
+ function setCfiusClearance(address buyer, bool cleared) external onlyAdmin {
+ if (buyer == address(0)) revert InvalidBuyer();
+ cfiusCleared[buyer] = cleared;
+ emit CfiusClearanceUpdated(buyer, cleared, msg.sender);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ if (!tidUsBusiness) return true;
+
+ Offer memory offer = dealManager.getOffer(offerId);
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // No buyer yet (posting context) — nothing to gate
+ if (buyer == address(0)) return true;
+
+ // A recorded clearance attestation satisfies the condition regardless of nationality
+ if (cfiusCleared[buyer]) return true;
+
+ // Non-U.S. persons require clearance
+ if (badge.hasValidCredentialOfKind(buyer, CategoryKind.NON_US_PERSON, "")) return false;
+ string memory jurisdiction = badge.getInvestorJurisdiction(buyer);
+ if (!_isUS(jurisdiction)) return false;
+
+ // U.S. persons from a blocked-affiliation jurisdiction list cannot occur (US-only above), but a
+ // buyer whose credential jurisdiction matches a blocked entry still requires clearance
+ bytes32 j = keccak256(bytes(jurisdiction));
+ for (uint256 i = 0; i < blockedJurisdictions.length; i++) {
+ if (keccak256(bytes(blockedJurisdictions[i])) == j) return false;
+ }
+ return true;
+ }
+
+ function _isUS(string memory jurisdiction) internal pure returns (bool) {
+ bytes32 h = keccak256(bytes(jurisdiction));
+ return h == keccak256("US") || h == keccak256("USA") || h == keccak256("United States");
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/ERISACondition.sol b/src/libs/conditions/secondary/ERISACondition.sol
new file mode 100644
index 00000000..d3672d2a
--- /dev/null
+++ b/src/libs/conditions/secondary/ERISACondition.sol
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ICyberAgreementRegistry.sol";
+import {Offer, ExemptionPathway} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title ERISACondition - verifies the buyer's ERISA negative attestation (no plan assets)
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition. Reads the buyer's attestation from their party values on the
+/// settlement agreement (partyB values, recorded at acceptance) and fails if it is absent. Not applied
+/// to Reg S trades — the Reg S pathway already requires a Rule 902(k) non-U.S. buyer via its own
+/// conditions, so this condition is silent there.
+contract ERISACondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidRegistry();
+ error InvalidAttestation();
+
+ event RegistryUpdated(address registry);
+ event AttestationValueUpdated(string attestationValue);
+
+ ICyberAgreementRegistry public registry;
+ /// @notice Exact party-value string the buyer must record as their ERISA negative attestation
+ string public attestationValue;
+
+ uint256[47] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, address _registry, string memory _attestationValue) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_registry == address(0)) revert InvalidRegistry();
+ if (bytes(_attestationValue).length == 0) revert InvalidAttestation();
+ registry = ICyberAgreementRegistry(_registry);
+ attestationValue = _attestationValue;
+ emit RegistryUpdated(_registry);
+ emit AttestationValueUpdated(_attestationValue);
+ }
+
+ function updateRegistry(address _registry) external onlyAdmin {
+ if (_registry == address(0)) revert InvalidRegistry();
+ registry = ICyberAgreementRegistry(_registry);
+ emit RegistryUpdated(_registry);
+ }
+
+ function updateAttestationValue(string memory _attestationValue) external onlyAdmin {
+ if (bytes(_attestationValue).length == 0) revert InvalidAttestation();
+ attestationValue = _attestationValue;
+ emit AttestationValueUpdated(_attestationValue);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+
+ // Silent for Reg S: non-U.S. buyer status is enforced by the pathway's own conditions
+ if (offer.exemptionPathway == ExemptionPathway.REGULATION_S) return true;
+
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // The attestation lives on the settlement agreement, which only exists from acceptance onward
+ if (agreementId == bytes32(0) || buyer == address(0)) return true;
+
+ string[] memory values = registry.getSignerValues(agreementId, buyer);
+ bytes32 expected = keccak256(bytes(attestationValue));
+ for (uint256 i = 0; i < values.length; i++) {
+ if (keccak256(bytes(values[i])) == expected) return true;
+ }
+ return false;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/GPLPApprovalCondition.sol b/src/libs/conditions/secondary/GPLPApprovalCondition.sol
new file mode 100644
index 00000000..9854bda3
--- /dev/null
+++ b/src/libs/conditions/secondary/GPLPApprovalCondition.sol
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+
+/// @title GPLPApprovalCondition - per-deal GP/LP manual approval gate
+/// @author MetaLeX Labs, Inc.
+/// @notice Generalizes IssuerApprovalRecertificationCondition to per-deal approval on the secondary
+/// path. Optional per-SPV; attached only where governing documents impose manual approval or LP-level
+/// consents (spousal consent, co-investor consents). Each DealManager's owner designates an authorized
+/// approver (GP, managing member, or delegated compliance officer); the condition fails until the
+/// approver has signed off on the specific deal — either the offerId (pre-approving every settlement of
+/// the offer) or a specific settlementAgreementId. Silent at posting (per-deal approval only exists
+/// once a settlement is in flight).
+contract GPLPApprovalCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidDealManager();
+ error InvalidDealId();
+ error NotApprover();
+
+ event ApproverUpdated(address indexed dealManager, address approver);
+ event DealApprovalUpdated(address indexed dealManager, bytes32 indexed dealId, bool approved, address indexed approver);
+
+ /// @notice Per-DealManager authorized approver
+ mapping(address => address) public approvers;
+ // dealManager => dealId (offerId or settlementAgreementId) => approved
+ mapping(address => mapping(bytes32 => bool)) public dealApprovals;
+
+ uint256[48] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ /// @notice Designates the authorized approver for a DealManager; only that DealManager's BorgAuth owner
+ function setApprover(address dealManager, address approver) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ _requireAuthOwner(dealManager);
+ approvers[dealManager] = approver;
+ emit ApproverUpdated(dealManager, approver);
+ }
+
+ /// @notice Records (or withdraws) the approver's sign-off on a specific deal id. Withdrawal bites at
+ /// finalize too, since threshold conditions are re-run there.
+ function setDealApproval(address dealManager, bytes32 dealId, bool approved) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ if (dealId == bytes32(0)) revert InvalidDealId();
+ if (msg.sender != approvers[dealManager]) revert NotApprover();
+ dealApprovals[dealManager][dealId] = approved;
+ emit DealApprovalUpdated(dealManager, dealId, approved, msg.sender);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ // Per-deal approval: nothing to verify until a settlement exists
+ if (agreementId == bytes32(0)) return true;
+
+ address dm = address(dealManager);
+ return dealApprovals[dm][offerId] || dealApprovals[dm][agreementId];
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/GlobalKillCondition.sol b/src/libs/conditions/secondary/GlobalKillCondition.sol
new file mode 100644
index 00000000..4914cbd4
--- /dev/null
+++ b/src/libs/conditions/secondary/GlobalKillCondition.sol
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "./SecondaryTradingConditionBase.sol";
+
+/// @title GlobalKillCondition - platform-wide finalization kill switch (closing condition)
+/// @author MetaLeX Labs, Inc.
+/// @notice Deployed once, platform-wide; the factory attaches it as a closing condition to every new
+/// DealManager. Two admin slots — one MetaLeX, one Legion. Either admin can raise the kill flag
+/// unilaterally; lowering requires two calls (one proposes, the other confirms). While raised,
+/// checkCondition returns false, suspending finalization platform-wide. Raising mid-deal does not
+/// unwind binding contracts; deals that expire while the kill is raised void per the standard expiry
+/// path (performance excused, no breach).
+/// @dev Deliberately not upgradeable and not BorgAuth-gated: the two-slot admin model IS the
+/// governance surface (spec §15 open questions — max raise duration, key rotation, key-loss recovery,
+/// tiered soft/hard kill — are future work).
+contract GlobalKillCondition is SecondaryTradingConditionBase {
+ error NotKillAdmin();
+ error InvalidAdmin();
+ error AlreadyKilled();
+ error NotKilled();
+ error NoLowerProposal();
+ error ProposerCannotConfirm();
+
+ event KillRaised(address indexed admin);
+ event KillLowerProposed(address indexed admin);
+ event KillLowerConfirmed(address indexed proposer, address indexed confirmer);
+ event AdminRotated(address indexed oldAdmin, address indexed newAdmin);
+
+ /// @notice Admin slot A (MetaLeX)
+ address public metalexAdmin;
+ /// @notice Admin slot B (Legion)
+ address public legionAdmin;
+
+ /// @notice Kill flag: while high, finalization is blocked platform-wide
+ bool public killed;
+ /// @notice Admin who proposed lowering the raised flag; zero when no proposal is pending
+ address public lowerProposer;
+
+ modifier onlyKillAdmin() {
+ if (msg.sender != metalexAdmin && msg.sender != legionAdmin) revert NotKillAdmin();
+ _;
+ }
+
+ constructor(address _metalexAdmin, address _legionAdmin) {
+ if (_metalexAdmin == address(0) || _legionAdmin == address(0)) revert InvalidAdmin();
+ if (_metalexAdmin == _legionAdmin) revert InvalidAdmin();
+ metalexAdmin = _metalexAdmin;
+ legionAdmin = _legionAdmin;
+ }
+
+ /// @notice Raises the kill flag; either admin, unilaterally. Also cancels any pending lower proposal.
+ function raiseKill() external onlyKillAdmin {
+ if (killed) revert AlreadyKilled();
+ killed = true;
+ lowerProposer = address(0);
+ emit KillRaised(msg.sender);
+ }
+
+ /// @notice First half of the two-call lowering: records the proposing admin
+ function proposeLower() external onlyKillAdmin {
+ if (!killed) revert NotKilled();
+ lowerProposer = msg.sender;
+ emit KillLowerProposed(msg.sender);
+ }
+
+ /// @notice Second half of the two-call lowering: the OTHER admin confirms and the flag drops
+ function confirmLower() external onlyKillAdmin {
+ if (!killed) revert NotKilled();
+ address proposer = lowerProposer;
+ if (proposer == address(0)) revert NoLowerProposal();
+ if (msg.sender == proposer) revert ProposerCannotConfirm();
+ killed = false;
+ lowerProposer = address(0);
+ emit KillLowerConfirmed(proposer, msg.sender);
+ }
+
+ /// @notice Each admin can rotate their own slot's key
+ function rotateAdmin(address newAdmin) external onlyKillAdmin {
+ if (newAdmin == address(0)) revert InvalidAdmin();
+ if (newAdmin == metalexAdmin || newAdmin == legionAdmin) revert InvalidAdmin();
+ if (msg.sender == metalexAdmin) {
+ metalexAdmin = newAdmin;
+ } else {
+ legionAdmin = newAdmin;
+ }
+ emit AdminRotated(msg.sender, newAdmin);
+ }
+
+ /// @notice Blocks finalization while the kill flag is high. Reads live state, so a kill raised
+ /// after an offer was posted (conditions are snapshotted onto offers) still bites at finalize.
+ function checkCondition(IDealManager, bytes4, bytes32, bytes32) external view override returns (bool) {
+ return !killed;
+ }
+}
diff --git a/src/libs/conditions/secondary/HolderCapCondition.sol b/src/libs/conditions/secondary/HolderCapCondition.sol
new file mode 100644
index 00000000..5d3f7a52
--- /dev/null
+++ b/src/libs/conditions/secondary/HolderCapCondition.sol
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {ICyberCertPrinter} from "../../../interfaces/ICyberCertPrinter.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title HolderCapCondition - ICA §3(c)(1) / §3(c)(1)(C) / §3(c)(7) holder limits at transfer time
+/// @author MetaLeX Labs, Inc.
+/// @notice Per-SPV deployment. Reads the onchain holder count from the SPV's ownership ledger (the
+/// offer's cert printer) at acceptance/finalization — not offer time — to avoid stale-count races, and
+/// the acquirer's entity-beneficial-owner-count credential attribute from LeXcheXBadge (§4.1.3A).
+/// If the acquirer is an entity that triggers §3(c)(1)(A) look-through (determined offchain during
+/// credentialing; reflected by a non-zero credentialed beneficial-owner count), the entity's credentialed
+/// count is added instead of counting it as one holder. A buyer who already holds interests in the SPV
+/// (position increase, not new holder) does not implicate the cap.
+contract HolderCapCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ /// @notice The ICA exception the SPV relies on (informational for the indexer/UI; the cap value and
+ /// counting mode below drive the enforcement)
+ enum IcaException {
+ SECTION_3C1, // 100-holder cap
+ SECTION_3C1C, // 250-holder cap (qualifying venture funds)
+ SECTION_3C7 // no numeric cap beyond the QP requirement (cap = 0)
+ }
+
+ error InvalidBadge();
+
+ event ConfigUpdated(
+ IcaException icaException,
+ uint256 cap,
+ bool usResidentOnlyCount,
+ bool blockUsInvestors
+ );
+ event BadgeUpdated(address badge);
+
+ ILexChexBadge public badge;
+ IcaException public icaException;
+ /// @notice Holder cap (100 / 250); 0 = no numeric cap (§3(c)(7))
+ uint256 public cap;
+ /// @notice Touche Remnant counting for non-U.S. SPVs: only U.S.-resident holders count toward the cap
+ bool public usResidentOnlyCount;
+ /// @notice Optional no-U.S.-investor floor for offshore SPVs: block U.S. acquirers entirely
+ bool public blockUsInvestors;
+
+ uint256[45] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(
+ address _auth,
+ address _badge,
+ IcaException _icaException,
+ uint256 _cap,
+ bool _usResidentOnlyCount,
+ bool _blockUsInvestors
+ ) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ icaException = _icaException;
+ cap = _cap;
+ usResidentOnlyCount = _usResidentOnlyCount;
+ blockUsInvestors = _blockUsInvestors;
+ emit BadgeUpdated(_badge);
+ emit ConfigUpdated(_icaException, _cap, _usResidentOnlyCount, _blockUsInvestors);
+ }
+
+ function updateBadge(address _badge) external onlyAdmin {
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function updateConfig(
+ IcaException _icaException,
+ uint256 _cap,
+ bool _usResidentOnlyCount,
+ bool _blockUsInvestors
+ ) external onlyAdmin {
+ icaException = _icaException;
+ cap = _cap;
+ usResidentOnlyCount = _usResidentOnlyCount;
+ blockUsInvestors = _blockUsInvestors;
+ emit ConfigUpdated(_icaException, _cap, _usResidentOnlyCount, _blockUsInvestors);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // No acquirer yet (posting context) — the cap is evaluated at acceptance and finalization
+ if (buyer == address(0)) return true;
+
+ bool buyerIsUS = _isUSBuyer(buyer);
+ if (blockUsInvestors && buyerIsUS) return false;
+
+ if (cap == 0) return true;
+
+ ICyberCertPrinter printer = ICyberCertPrinter(offer.certPrinter);
+
+ // Position increase, not a new holder: the cap is not implicated
+ if (printer.balanceOfLegalOwner(buyer) > 0) return true;
+
+ // Touche Remnant: a non-U.S. acquirer does not add to the U.S.-resident-only count
+ if (usResidentOnlyCount && !buyerIsUS) return true;
+
+ uint256 currentCount = usResidentOnlyCount
+ ? _usResidentHolderCount(printer)
+ : printer.holderCount();
+
+ // §3(c)(1)(A) look-through: a credentialed entity BO count flows through instead of 1
+ uint32 boCount = badge.getBeneficialOwnerCount(buyer);
+ uint256 addition = boCount > 0 ? boCount : 1;
+
+ return currentCount + addition <= cap;
+ }
+
+ /// @dev Counts unique legal owners whose credential marks them U.S.-resident. O(n²) over the token
+ /// set, acceptable at holder-cap scale (n ≤ 250 by construction).
+ function _usResidentHolderCount(ICyberCertPrinter printer) internal view returns (uint256 count) {
+ uint256 supply = printer.totalSupply();
+ address[] memory seen = new address[](supply);
+ uint256 seenLen;
+ for (uint256 i = 0; i < supply; i++) {
+ address holder = printer.legalOwnerOf(printer.tokenByIndex(i));
+ if (holder == address(0)) continue;
+ bool duplicate = false;
+ for (uint256 j = 0; j < seenLen; j++) {
+ if (seen[j] == holder) {
+ duplicate = true;
+ break;
+ }
+ }
+ if (duplicate) continue;
+ seen[seenLen++] = holder;
+ if (_isUSBuyer(holder)) count++;
+ }
+ }
+
+ function _isUSBuyer(address account) internal view returns (bool) {
+ bytes32 h = keccak256(bytes(badge.getInvestorJurisdiction(account)));
+ return h == keccak256("US") || h == keccak256("USA") || h == keccak256("United States");
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/HoldingPeriodCondition.sol b/src/libs/conditions/secondary/HoldingPeriodCondition.sol
new file mode 100644
index 00000000..16badfc0
--- /dev/null
+++ b/src/libs/conditions/secondary/HoldingPeriodCondition.sol
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import {FundInterestData} from "../../../storage/extensions/FundInterestExtension.sol";
+import {ICyberCertPrinter} from "../../../interfaces/ICyberCertPrinter.sol";
+import {Offer, OfferSide} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title HoldingPeriodCondition - Rule 144 holding-period verification
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared (generic) threshold condition, attached per exemption pathway (Rule 144). Reads
+/// acquisitionDate and tackedFromAcquisitionDate from the seller certificate's FundInterestExtension
+/// data (§12B.3). Where Rule 144(d)(3) tacking is asserted (non-zero tackedFromAcquisitionDate), the
+/// earlier of the two dates applies; otherwise acquisitionDate alone. Fails if the required hold
+/// (one year for non-reporting issuers) has not elapsed.
+contract HoldingPeriodCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidHoldingPeriod();
+
+ event HoldingPeriodUpdated(uint256 holdingPeriod);
+
+ /// @notice Required hold in seconds (default 365 days: Rule 144(d) for non-reporting issuers)
+ uint256 public holdingPeriod;
+
+ uint256[49] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, uint256 _holdingPeriod) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_holdingPeriod == 0) revert InvalidHoldingPeriod();
+ holdingPeriod = _holdingPeriod;
+ emit HoldingPeriodUpdated(_holdingPeriod);
+ }
+
+ function updateHoldingPeriod(uint256 _holdingPeriod) external onlyAdmin {
+ if (_holdingPeriod == 0) revert InvalidHoldingPeriod();
+ holdingPeriod = _holdingPeriod;
+ emit HoldingPeriodUpdated(_holdingPeriod);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+
+ // A bid at posting has no seller (and no token) yet: the hold is verified at acceptance
+ if (agreementId == bytes32(0) && offer.side == OfferSide.BUY) return true;
+
+ (,, uint256 sellerTokenId) = _resolveParties(dealManager, offer, agreementId);
+
+ bytes memory extensionData =
+ ICyberCertPrinter(offer.certPrinter).getCertificateDetails(sellerTokenId).extensionData;
+ // No fund-interest record = the holding period cannot be verified: fail closed
+ if (extensionData.length == 0) return false;
+
+ FundInterestData memory data = abi.decode(extensionData, (FundInterestData));
+ uint64 anchor = data.acquisitionDate;
+ if (data.tackedFromAcquisitionDate != 0 && data.tackedFromAcquisitionDate < anchor) {
+ anchor = data.tackedFromAcquisitionDate;
+ }
+ if (anchor == 0) return false;
+
+ return block.timestamp >= uint256(anchor) + holdingPeriod;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/KYCAMLCondition.sol b/src/libs/conditions/secondary/KYCAMLCondition.sol
new file mode 100644
index 00000000..6b82065b
--- /dev/null
+++ b/src/libs/conditions/secondary/KYCAMLCondition.sol
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {CategoryKind} from "../../../creds/storage/lexchexBadgeStorage.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title KYCAMLCondition - both parties must hold valid, unexpired KYC/AML badges
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared (singleton) threshold condition. Reads the LeXcheXBadge credentialing layer for both
+/// seller and buyer and fails on any expired, voided, or absent badge. Refresh cadence is a
+/// credentialing-layer policy (category defaultValidityDuration), not condition logic.
+contract KYCAMLCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidBadge();
+
+ event BadgeUpdated(address badge);
+
+ ILexChexBadge public badge;
+
+ uint256[49] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, address _badge) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function updateBadge(address _badge) external onlyAdmin {
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (address seller, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // Unknown parties (posting context) short-circuit; known parties enforce.
+ if (seller != address(0) && !badge.hasValidCredentialOfKind(seller, CategoryKind.KYC_AML, "")) {
+ return false;
+ }
+ if (buyer != address(0) && !badge.hasValidCredentialOfKind(buyer, CategoryKind.KYC_AML, "")) {
+ return false;
+ }
+ return true;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/LegalOpinionCondition.sol b/src/libs/conditions/secondary/LegalOpinionCondition.sol
new file mode 100644
index 00000000..c06f8c8f
--- /dev/null
+++ b/src/libs/conditions/secondary/LegalOpinionCondition.sol
@@ -0,0 +1,132 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title LegalOpinionCondition - §4(a)(1½) GP/issuer-counsel assurance gate
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition with per-SPV configuration (§6.3): each SPV chooses between the
+/// lower-effort GP sign-off mechanism, the formal opinion-upload mechanism (hash/URI of an opinion
+/// submitted via cyberSign), or accepting either (the default). Records are keyed per deal id — either
+/// the offerId (pre-approving every settlement of the offer) or a specific settlementAgreementId — and
+/// both record types are written under the SPV's own BorgAuth admin (the GP, or counsel delegated
+/// ADMIN_ROLE). Silent at posting: the assurance is a per-deal artifact evaluated from acceptance onward.
+contract LegalOpinionCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ enum OpinionMechanism {
+ EITHER, // default: GP sign-off or formal opinion satisfies
+ GP_SIGNOFF, // only a recorded GP sign-off satisfies
+ FORMAL_OPINION // only a recorded formal opinion satisfies
+ }
+
+ struct OpinionRecord {
+ bytes32 opinionHash; // hash of the opinion document
+ string uri; // cyberSign / offchain record locator
+ address submitter;
+ uint64 submittedAt;
+ }
+
+ error InvalidSpv();
+ error InvalidDealManager();
+ error InvalidDealId();
+ error InvalidOpinionHash();
+
+ event MechanismUpdated(address indexed spv, OpinionMechanism mechanism);
+ event GPSignOffRecorded(address indexed dealManager, bytes32 indexed dealId, address indexed approver);
+ event GPSignOffRevoked(address indexed dealManager, bytes32 indexed dealId, address indexed approver);
+ event OpinionSubmitted(address indexed dealManager, bytes32 indexed dealId, bytes32 opinionHash, string uri, address indexed submitter);
+
+ /// @notice Per-SPV (cyberCORP address) mechanism selection; EITHER (enum zero) when unconfigured
+ mapping(address => OpinionMechanism) public mechanisms;
+ // dealManager => dealId (offerId or settlementAgreementId) => GP sign-off recorded
+ mapping(address => mapping(bytes32 => bool)) public gpSignOffs;
+ // dealManager => dealId (offerId or settlementAgreementId) => formal opinion record
+ mapping(address => mapping(bytes32 => OpinionRecord)) public opinions;
+
+ uint256[47] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ /// @notice Selects an SPV's assurance mechanism; only the SPV's own BorgAuth admin
+ function setMechanism(address spv, OpinionMechanism mechanism) external {
+ if (spv == address(0)) revert InvalidSpv();
+ _requireAuthAdmin(spv);
+ mechanisms[spv] = mechanism;
+ emit MechanismUpdated(spv, mechanism);
+ }
+
+ /// @notice Records the GP's compliance sign-off for a deal (offerId to pre-approve all its
+ /// settlements, or a specific settlementAgreementId); only the DealManager's BorgAuth admin
+ function recordGPSignOff(address dealManager, bytes32 dealId) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ if (dealId == bytes32(0)) revert InvalidDealId();
+ _requireAuthAdmin(dealManager);
+ gpSignOffs[dealManager][dealId] = true;
+ emit GPSignOffRecorded(dealManager, dealId, msg.sender);
+ }
+
+ /// @notice Withdraws a previously recorded sign-off (threshold conditions re-run at finalize, so a
+ /// revocation between acceptance and settlement blocks the asset transfer)
+ function revokeGPSignOff(address dealManager, bytes32 dealId) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ _requireAuthAdmin(dealManager);
+ gpSignOffs[dealManager][dealId] = false;
+ emit GPSignOffRevoked(dealManager, dealId, msg.sender);
+ }
+
+ /// @notice Anchors a formal legal opinion (hash/URI of the cyberSign record) for a deal;
+ /// only the DealManager's BorgAuth admin (GP or delegated counsel)
+ function submitOpinion(address dealManager, bytes32 dealId, bytes32 opinionHash, string memory uri) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ if (dealId == bytes32(0)) revert InvalidDealId();
+ if (opinionHash == bytes32(0)) revert InvalidOpinionHash();
+ _requireAuthAdmin(dealManager);
+ opinions[dealManager][dealId] = OpinionRecord({
+ opinionHash: opinionHash,
+ uri: uri,
+ submitter: msg.sender,
+ submittedAt: uint64(block.timestamp)
+ });
+ emit OpinionSubmitted(dealManager, dealId, opinionHash, uri, msg.sender);
+ }
+
+ /// @notice Whether a satisfying assurance record exists for the deal under the SPV's mechanism
+ function hasAssurance(address spv, address dealManager, bytes32 offerId, bytes32 agreementId) public view returns (bool) {
+ OpinionMechanism mechanism = mechanisms[spv];
+ if (mechanism != OpinionMechanism.FORMAL_OPINION) {
+ if (gpSignOffs[dealManager][offerId]) return true;
+ if (agreementId != bytes32(0) && gpSignOffs[dealManager][agreementId]) return true;
+ }
+ if (mechanism != OpinionMechanism.GP_SIGNOFF) {
+ if (opinions[dealManager][offerId].opinionHash != bytes32(0)) return true;
+ if (agreementId != bytes32(0) && opinions[dealManager][agreementId].opinionHash != bytes32(0)) return true;
+ }
+ return false;
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ // Per-deal assurance: nothing to verify until a settlement exists
+ if (agreementId == bytes32(0)) return true;
+
+ Offer memory offer = dealManager.getOffer(offerId);
+ return hasAssurance(offer.spvAddress, address(dealManager), offerId, agreementId);
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/LegionSoulboundCondition.sol b/src/libs/conditions/secondary/LegionSoulboundCondition.sol
new file mode 100644
index 00000000..36d5b67e
--- /dev/null
+++ b/src/libs/conditions/secondary/LegionSoulboundCondition.sol
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title LegionSoulboundCondition - issuer-specific credential category/tier gate
+/// @author MetaLeX Labs, Inc.
+/// @notice Thin condition querying an operator's custom credentialing layer (a LeXcheXBadge deployment)
+/// for issuer-specific gating not captured by generic credentials — syndicate-circle restrictions,
+/// non-accredited-tier requirements. Configured with the required category and whether the check
+/// applies to buyer only or buyer and seller.
+contract LegionSoulboundCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidBadge();
+ error InvalidCategory();
+
+ event ConfigUpdated(address badge, bytes32 requiredCategoryId, bool applyToSeller);
+
+ ILexChexBadge public badge;
+ bytes32 public requiredCategoryId;
+ bool public applyToSeller;
+
+ uint256[47] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(
+ address _auth,
+ address _badge,
+ bytes32 _requiredCategoryId,
+ bool _applyToSeller
+ ) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ _setConfig(_badge, _requiredCategoryId, _applyToSeller);
+ }
+
+ function updateConfig(address _badge, bytes32 _requiredCategoryId, bool _applyToSeller) external onlyAdmin {
+ _setConfig(_badge, _requiredCategoryId, _applyToSeller);
+ }
+
+ function _setConfig(address _badge, bytes32 _requiredCategoryId, bool _applyToSeller) internal {
+ if (_badge == address(0)) revert InvalidBadge();
+ if (_requiredCategoryId == bytes32(0)) revert InvalidCategory();
+ badge = ILexChexBadge(_badge);
+ requiredCategoryId = _requiredCategoryId;
+ applyToSeller = _applyToSeller;
+ emit ConfigUpdated(_badge, _requiredCategoryId, _applyToSeller);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (address seller, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ if (buyer != address(0) && !badge.hasValidCredential(buyer, requiredCategoryId)) {
+ return false;
+ }
+ if (applyToSeller && seller != address(0) && !badge.hasValidCredential(seller, requiredCategoryId)) {
+ return false;
+ }
+ return true;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/LexChexBadgeKindCondition.sol b/src/libs/conditions/secondary/LexChexBadgeKindCondition.sol
new file mode 100644
index 00000000..23393686
--- /dev/null
+++ b/src/libs/conditions/secondary/LexChexBadgeKindCondition.sol
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {CategoryKind} from "../../../creds/storage/lexchexBadgeStorage.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title LexChexBadgeKindCondition - parameterizable investor-status gate on the LeXcheXBadge layer
+/// @author MetaLeX Labs, Inc.
+/// @notice The secondary-trading successor of LexChexCondition: one primitive, deployed per
+/// parameterization (spec §B):
+/// - AccreditedInvestorCondition: kind = ACCREDITED_INVESTOR, buyer only — required for §4(a)(7) trades
+/// and typically by operating agreements generally
+/// - QualifiedPurchaserCondition: kind = QUALIFIED_PURCHASER, buyer + seller — §3(c)(7) funds only
+/// - QualifiedInstitutionalBuyerCondition: kind = QIB, buyer only — Rule 144A pathway only
+/// An optional investorType filter narrows within the kind (e.g. accredited / QP / QIB subtype strings).
+contract LexChexBadgeKindCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidBadge();
+
+ event BadgeUpdated(address badge);
+ event ParametersUpdated(CategoryKind kind, string investorTypeFilter, bool checkSeller);
+
+ ILexChexBadge public badge;
+ CategoryKind public kind;
+ string public investorTypeFilter;
+ bool public checkSeller;
+
+ uint256[46] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(
+ address _auth,
+ address _badge,
+ CategoryKind _kind,
+ string memory _investorTypeFilter,
+ bool _checkSeller
+ ) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ kind = _kind;
+ investorTypeFilter = _investorTypeFilter;
+ checkSeller = _checkSeller;
+ emit BadgeUpdated(_badge);
+ emit ParametersUpdated(_kind, _investorTypeFilter, _checkSeller);
+ }
+
+ function updateBadge(address _badge) external onlyAdmin {
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function updateParameters(
+ CategoryKind _kind,
+ string memory _investorTypeFilter,
+ bool _checkSeller
+ ) external onlyAdmin {
+ kind = _kind;
+ investorTypeFilter = _investorTypeFilter;
+ checkSeller = _checkSeller;
+ emit ParametersUpdated(_kind, _investorTypeFilter, _checkSeller);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (address seller, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ if (buyer != address(0) && !badge.hasValidCredentialOfKind(buyer, kind, investorTypeFilter)) {
+ return false;
+ }
+ if (checkSeller && seller != address(0) && !badge.hasValidCredentialOfKind(seller, kind, investorTypeFilter)) {
+ return false;
+ }
+ return true;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/RegSDistributionComplianceCondition.sol b/src/libs/conditions/secondary/RegSDistributionComplianceCondition.sol
new file mode 100644
index 00000000..6de968f3
--- /dev/null
+++ b/src/libs/conditions/secondary/RegSDistributionComplianceCondition.sol
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import {FundInterestData} from "../../../storage/extensions/FundInterestExtension.sol";
+import {ICyberCertPrinter} from "../../../interfaces/ICyberCertPrinter.sol";
+import {Offer, OfferSide} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title RegSDistributionComplianceCondition - Regulation S distribution compliance period
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition for the Reg S pathway. The applicable compliance period is a
+/// function of the SPV's domicile and Reg S issuer category (1/2/3) — determined by counsel and encoded
+/// here at configuration by the SPV's admin (e.g. one year for Category 3 U.S. equity). Fails if the
+/// period has not elapsed since the interest's acquisitionDate (FundInterestExtension data, §12B.3).
+contract RegSDistributionComplianceCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ struct RegSConfig {
+ uint8 issuerCategory; // Reg S issuer category 1 / 2 / 3 (informational)
+ uint64 compliancePeriod; // seconds; the encoded distribution compliance period
+ bool configured;
+ }
+
+ error InvalidSpv();
+ error InvalidCategory();
+
+ event RegSConfigUpdated(address indexed spv, uint8 issuerCategory, uint64 compliancePeriod);
+
+ /// @notice Per-SPV (cyberCORP address) Reg S parameterization
+ mapping(address => RegSConfig) public regSConfigs;
+
+ uint256[49] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ /// @notice Encodes an SPV's Reg S category and compliance period; only the SPV's own BorgAuth admin
+ function setRegSConfig(address spv, uint8 issuerCategory, uint64 compliancePeriod) external {
+ if (spv == address(0)) revert InvalidSpv();
+ if (issuerCategory == 0 || issuerCategory > 3) revert InvalidCategory();
+ _requireAuthAdmin(spv);
+ regSConfigs[spv] = RegSConfig({
+ issuerCategory: issuerCategory,
+ compliancePeriod: compliancePeriod,
+ configured: true
+ });
+ emit RegSConfigUpdated(spv, issuerCategory, compliancePeriod);
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+
+ // A Reg S resale from an unconfigured SPV fails closed
+ RegSConfig memory config = regSConfigs[offer.spvAddress];
+ if (!config.configured) return false;
+
+ // A bid at posting has no seller (and no token) yet: the period is verified at acceptance
+ if (agreementId == bytes32(0) && offer.side == OfferSide.BUY) return true;
+
+ (,, uint256 sellerTokenId) = _resolveParties(dealManager, offer, agreementId);
+
+ bytes memory extensionData =
+ ICyberCertPrinter(offer.certPrinter).getCertificateDetails(sellerTokenId).extensionData;
+ if (extensionData.length == 0) return false;
+
+ FundInterestData memory data = abi.decode(extensionData, (FundInterestData));
+ if (data.acquisitionDate == 0) return false;
+
+ return block.timestamp >= uint256(data.acquisitionDate) + config.compliancePeriod;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/Rule144DisclosureCondition.sol b/src/libs/conditions/secondary/Rule144DisclosureCondition.sol
new file mode 100644
index 00000000..74bbb364
--- /dev/null
+++ b/src/libs/conditions/secondary/Rule144DisclosureCondition.sol
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title Rule144DisclosureCondition - current public information gate for Rule 144 trades
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition implementing Rule 144(c)(2) / 15c2-11(b)(5): the SPV must have a
+/// current disclosure package on record. The package itself stays offchain; the SPV's admin records its
+/// URI and as-of timestamp here (the cyberCORP-metadata anchor), and the condition fails when the record
+/// is missing or stale (e.g. balance sheet older than 16 months per the freshness policy encoded at
+/// configuration). No party lookup: the gate is SPV-wide and enforced at posting, acceptance, and finalize.
+contract Rule144DisclosureCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ struct DisclosureRecord {
+ string uri; // offchain 144(c)(2) information package
+ uint64 asOf; // freshness timestamp (e.g. balance-sheet date)
+ }
+
+ error InvalidSpv();
+ error InvalidMaxAge();
+ error InvalidTimestamp();
+
+ event DisclosurePackageUpdated(address indexed spv, string uri, uint64 asOf);
+ event MaxAgeUpdated(uint256 maxAge);
+
+ /// @notice Freshness policy in seconds (Rule 144(c)(2) practice: balance sheet no older than 16 months)
+ uint256 public maxAge;
+
+ /// @notice Per-SPV (cyberCORP address) disclosure record
+ mapping(address => DisclosureRecord) public disclosures;
+
+ uint256[48] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, uint256 _maxAge) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_maxAge == 0) revert InvalidMaxAge();
+ maxAge = _maxAge;
+ emit MaxAgeUpdated(_maxAge);
+ }
+
+ function updateMaxAge(uint256 _maxAge) external onlyAdmin {
+ if (_maxAge == 0) revert InvalidMaxAge();
+ maxAge = _maxAge;
+ emit MaxAgeUpdated(_maxAge);
+ }
+
+ /// @notice Records/refreshes an SPV's disclosure package; only the SPV's own BorgAuth admin
+ function setDisclosurePackage(address spv, string memory uri, uint64 asOf) external {
+ if (spv == address(0)) revert InvalidSpv();
+ if (asOf == 0 || asOf > block.timestamp) revert InvalidTimestamp();
+ _requireAuthAdmin(spv);
+ disclosures[spv] = DisclosureRecord({uri: uri, asOf: asOf});
+ emit DisclosurePackageUpdated(spv, uri, asOf);
+ }
+
+ /// @notice True when the SPV's disclosure record exists and is within the freshness policy
+ function isDisclosureCurrent(address spv) public view returns (bool) {
+ DisclosureRecord storage record = disclosures[spv];
+ if (record.asOf == 0) return false;
+ return block.timestamp <= uint256(record.asOf) + maxAge;
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ return isDisclosureCurrent(offer.spvAddress);
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/SecondaryTradingConditionBase.sol b/src/libs/conditions/secondary/SecondaryTradingConditionBase.sol
new file mode 100644
index 00000000..27aa56a3
--- /dev/null
+++ b/src/libs/conditions/secondary/SecondaryTradingConditionBase.sol
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "../BaseSecondaryTradingCondition.sol";
+import "../../auth.sol";
+import {Offer, SecondaryEscrow, OfferSide} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @notice Minimal surface for reading the BorgAuth wired into a CyberCorp / DealManager
+/// (both inherit BorgAuthACL, whose public AUTH getter this matches).
+interface IBorgAuthProvider {
+ function AUTH() external view returns (address);
+}
+
+/// @title SecondaryTradingConditionBase - shared helpers for secondary-trading conditions
+/// @author MetaLeX Labs, Inc.
+/// @notice Extends BaseSecondaryTradingCondition with the two things nearly every threshold condition
+/// needs: resolving the settlement's seller/buyer from the offer + escrow (per the conditions spec, the
+/// buyer is unknown at postOffer — agreementId == bytes32(0) — so buyer-facing checks short-circuit),
+/// and gating per-SPV configuration setters on the SPV's own BorgAuth.
+abstract contract SecondaryTradingConditionBase is BaseSecondaryTradingCondition {
+ /// @dev Derives the parties of the evaluation context.
+ /// At posting (agreementId == 0) only the offeror's side is known: SELL → seller, BUY → buyer.
+ /// At acceptance/finalization the escrow's counterparty fills the other side.
+ /// `sellerTokenId` is the seller's Ledger Entry Token id when known (0 for a bid at posting).
+ function _resolveParties(
+ IDealManager dealManager,
+ Offer memory offer,
+ bytes32 agreementId
+ ) internal view returns (address seller, address buyer, uint256 sellerTokenId) {
+ if (agreementId == bytes32(0)) {
+ if (offer.side == OfferSide.SELL) {
+ seller = offer.offeror;
+ sellerTokenId = offer.tokenId;
+ } else {
+ buyer = offer.offeror;
+ }
+ } else {
+ SecondaryEscrow memory escrow = dealManager.getSecondaryEscrow(agreementId);
+ if (offer.side == OfferSide.SELL) {
+ seller = offer.offeror;
+ buyer = escrow.counterparty;
+ } else {
+ seller = escrow.counterparty;
+ buyer = offer.offeror;
+ }
+ sellerTokenId = escrow.tokenId;
+ }
+ }
+
+ /// @dev Reverts unless msg.sender holds ADMIN_ROLE (or above) on the target's BorgAuth.
+ /// Used to gate per-SPV configuration on the SPV's (or its DealManager's) own authority.
+ function _requireAuthAdmin(address authProvider) internal view {
+ BorgAuth auth = BorgAuth(IBorgAuthProvider(authProvider).AUTH());
+ auth.onlyRole(auth.ADMIN_ROLE(), msg.sender);
+ }
+
+ /// @dev Reverts unless msg.sender holds OWNER_ROLE on the target's BorgAuth.
+ function _requireAuthOwner(address authProvider) internal view {
+ BorgAuth auth = BorgAuth(IBorgAuthProvider(authProvider).AUTH());
+ auth.onlyRole(auth.OWNER_ROLE(), msg.sender);
+ }
+}
diff --git a/src/libs/conditions/secondary/Section4a7DisclosureCondition.sol b/src/libs/conditions/secondary/Section4a7DisclosureCondition.sol
new file mode 100644
index 00000000..b73f07ea
--- /dev/null
+++ b/src/libs/conditions/secondary/Section4a7DisclosureCondition.sol
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ICyberAgreementRegistry.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title Section4a7DisclosureCondition - §4(a)(7) information-delivery gate
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition. Two-part test per §4(a)(7)(d)(3):
+/// 1. The SPV's information package (incl. two years of GAAP financials) must be on record and fresh —
+/// the SPV's admin anchors its URI + as-of timestamp here; checked from posting onward.
+/// 2. The buyer must have acknowledged receipt — read from the buyer's party values on the settlement
+/// agreement (recorded at acceptance), matched against the configured acknowledgment string.
+contract Section4a7DisclosureCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ struct DisclosureRecord {
+ string uri; // offchain §4(a)(7)(d)(3) information package
+ uint64 asOf; // freshness timestamp
+ }
+
+ error InvalidSpv();
+ error InvalidMaxAge();
+ error InvalidTimestamp();
+ error InvalidRegistry();
+ error InvalidAcknowledgment();
+
+ event DisclosurePackageUpdated(address indexed spv, string uri, uint64 asOf);
+ event MaxAgeUpdated(uint256 maxAge);
+ event RegistryUpdated(address registry);
+ event AcknowledgmentValueUpdated(string acknowledgmentValue);
+
+ ICyberAgreementRegistry public registry;
+ /// @notice Exact party-value string the buyer must record as their acknowledgment of receipt
+ string public acknowledgmentValue;
+ /// @notice Freshness policy in seconds for the information package
+ uint256 public maxAge;
+
+ /// @notice Per-SPV (cyberCORP address) disclosure record
+ mapping(address => DisclosureRecord) public disclosures;
+
+ uint256[46] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(
+ address _auth,
+ address _registry,
+ string memory _acknowledgmentValue,
+ uint256 _maxAge
+ ) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_registry == address(0)) revert InvalidRegistry();
+ if (bytes(_acknowledgmentValue).length == 0) revert InvalidAcknowledgment();
+ if (_maxAge == 0) revert InvalidMaxAge();
+ registry = ICyberAgreementRegistry(_registry);
+ acknowledgmentValue = _acknowledgmentValue;
+ maxAge = _maxAge;
+ emit RegistryUpdated(_registry);
+ emit AcknowledgmentValueUpdated(_acknowledgmentValue);
+ emit MaxAgeUpdated(_maxAge);
+ }
+
+ function updateRegistry(address _registry) external onlyAdmin {
+ if (_registry == address(0)) revert InvalidRegistry();
+ registry = ICyberAgreementRegistry(_registry);
+ emit RegistryUpdated(_registry);
+ }
+
+ function updateAcknowledgmentValue(string memory _acknowledgmentValue) external onlyAdmin {
+ if (bytes(_acknowledgmentValue).length == 0) revert InvalidAcknowledgment();
+ acknowledgmentValue = _acknowledgmentValue;
+ emit AcknowledgmentValueUpdated(_acknowledgmentValue);
+ }
+
+ function updateMaxAge(uint256 _maxAge) external onlyAdmin {
+ if (_maxAge == 0) revert InvalidMaxAge();
+ maxAge = _maxAge;
+ emit MaxAgeUpdated(_maxAge);
+ }
+
+ /// @notice Records/refreshes an SPV's information package; only the SPV's own BorgAuth admin
+ function setDisclosurePackage(address spv, string memory uri, uint64 asOf) external {
+ if (spv == address(0)) revert InvalidSpv();
+ if (asOf == 0 || asOf > block.timestamp) revert InvalidTimestamp();
+ _requireAuthAdmin(spv);
+ disclosures[spv] = DisclosureRecord({uri: uri, asOf: asOf});
+ emit DisclosurePackageUpdated(spv, uri, asOf);
+ }
+
+ /// @notice True when the SPV's information package exists and is within the freshness policy
+ function isDisclosureCurrent(address spv) public view returns (bool) {
+ DisclosureRecord storage record = disclosures[spv];
+ if (record.asOf == 0) return false;
+ return block.timestamp <= uint256(record.asOf) + maxAge;
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+
+ // Part 1 — SPV-wide, enforced from posting onward: the package must exist and be fresh
+ if (!isDisclosureCurrent(offer.spvAddress)) return false;
+
+ // Part 2 — buyer acknowledgment, which lives on the settlement agreement (acceptance onward)
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+ if (agreementId == bytes32(0) || buyer == address(0)) return true;
+
+ string[] memory values = registry.getSignerValues(agreementId, buyer);
+ bytes32 expected = keccak256(bytes(acknowledgmentValue));
+ for (uint256 i = 0; i < values.length; i++) {
+ if (keccak256(bytes(values[i])) == expected) return true;
+ }
+ return false;
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/TaxInfoCondition.sol b/src/libs/conditions/secondary/TaxInfoCondition.sol
new file mode 100644
index 00000000..7756685f
--- /dev/null
+++ b/src/libs/conditions/secondary/TaxInfoCondition.sol
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title TaxInfoCondition - blocks acceptance until tax information is on file
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared threshold condition backing K-1 readiness and the §1446(f) withholding posture.
+/// Acts as the dedicated tax-form registry: the credentialing-layer admin records each account's
+/// W-9 / W-8BEN(-E) file (the form itself stays offchain; the hash is the audit anchor).
+/// Fails when the buyer's form is not recorded. The seller's record (which should exist from primary
+/// issuance) is readable here for the §1446(f) determination but does not gate the trade.
+contract TaxInfoCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ enum TaxFormType {
+ NONE,
+ W9,
+ W8BEN,
+ W8BENE
+ }
+
+ struct TaxFormRecord {
+ TaxFormType formType;
+ bytes32 evidenceHash; // hash of the offchain form/file
+ uint64 recordedAt;
+ }
+
+ error InvalidAccount();
+
+ event TaxFormRecorded(address indexed account, TaxFormType formType, bytes32 evidenceHash);
+ event TaxFormCleared(address indexed account);
+
+ mapping(address => TaxFormRecord) public taxForms;
+
+ uint256[49] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function setTaxForm(address account, TaxFormType formType, bytes32 evidenceHash) external onlyAdmin {
+ if (account == address(0)) revert InvalidAccount();
+ taxForms[account] = TaxFormRecord({
+ formType: formType,
+ evidenceHash: evidenceHash,
+ recordedAt: uint64(block.timestamp)
+ });
+ emit TaxFormRecorded(account, formType, evidenceHash);
+ }
+
+ function clearTaxForm(address account) external onlyAdmin {
+ delete taxForms[account];
+ emit TaxFormCleared(account);
+ }
+
+ function hasTaxFormOnFile(address account) public view returns (bool) {
+ return taxForms[account].formType != TaxFormType.NONE;
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // No buyer yet (posting context) — nothing to gate
+ if (buyer == address(0)) return true;
+
+ return hasTaxFormOnFile(buyer);
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/libs/conditions/secondary/TimeSettlementPeriodCondition.sol b/src/libs/conditions/secondary/TimeSettlementPeriodCondition.sol
new file mode 100644
index 00000000..40c0da1a
--- /dev/null
+++ b/src/libs/conditions/secondary/TimeSettlementPeriodCondition.sol
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "./SecondaryTradingConditionBase.sol";
+import {SecondaryEscrow} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title TimeSettlementPeriodCondition - minimum delay between acceptance and finalization (closing condition)
+/// @author MetaLeX Labs, Inc.
+/// @notice Deployed once, platform-wide; attached by default to every DealManager, with per-DealManager
+/// delay overrides. Structural defense against key-theft dumps: the enforced window between acceptance
+/// and finalization is the intervention window for the Compromised Credential Transfer voidness
+/// provision and the Global Kill. It is also the trigger the keeper waits on before auto-finalizing.
+/// Default delay is 86,400 seconds (24h) measured from acceptance (the unified pathway's start trigger —
+/// under this architecture the buyer's deposit is atomic with acceptance, so acceptance and deposit
+/// triggers coincide). Future QMS parameterization (Addendum E): a 45-day delay measured from the
+/// offer's listing timestamp for QMS-mode SPVs — same contract, different per-DealManager delay.
+/// @dev The acceptance timestamp is reconstructed as escrow.expiry - settlementWindow (acceptOffer
+/// stamps expiry = acceptance + window). If the DealManager's settlement window is reconfigured while a
+/// lot is in flight, the reconstruction shifts with it; owners should change the window only between
+/// settlements. A DealManager's effective delay must stay below its settlement window or no lot can
+/// ever finalize.
+contract TimeSettlementPeriodCondition is SecondaryTradingConditionBase {
+ error InvalidDealManager();
+ error NotDealManagerOwner();
+
+ event DelayOverrideUpdated(address indexed dealManager, uint256 delay);
+
+ /// @notice Default minimum settlement delay: 24 hours from acceptance
+ uint256 public constant DEFAULT_DELAY = 86_400;
+
+ /// @notice Per-DealManager delay override in seconds; 0 = DEFAULT_DELAY
+ mapping(address => uint256) public delayOverrides;
+
+ /// @notice Sets a DealManager's delay override (0 restores the default); only that DealManager's
+ /// BorgAuth owner
+ function setDelayOverride(address dealManager, uint256 delay) external {
+ if (dealManager == address(0)) revert InvalidDealManager();
+ _requireAuthOwner(dealManager);
+ delayOverrides[dealManager] = delay;
+ emit DelayOverrideUpdated(dealManager, delay);
+ }
+
+ /// @notice Effective delay for a DealManager
+ function delayFor(address dealManager) public view returns (uint256) {
+ uint256 delay = delayOverrides[dealManager];
+ return delay == 0 ? DEFAULT_DELAY : delay;
+ }
+
+ /// @notice Earliest timestamp at which a settlement lot may finalize (what a keeper waits on)
+ function finalizableAt(IDealManager dealManager, bytes32 agreementId) public view returns (uint256) {
+ SecondaryEscrow memory escrow = dealManager.getSecondaryEscrow(agreementId);
+ uint256 acceptedAt = escrow.expiry - dealManager.getSettlementWindow();
+ return acceptedAt + delayFor(address(dealManager));
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ // Closing conditions are only evaluated at finalize, but stay silent for a missing settlement id
+ // in case a flow composes conditions independently
+ if (agreementId == bytes32(0)) return true;
+ return block.timestamp >= finalizableAt(dealManager, agreementId);
+ }
+}
diff --git a/src/libs/conditions/secondary/USStateOfResidenceCondition.sol b/src/libs/conditions/secondary/USStateOfResidenceCondition.sol
new file mode 100644
index 00000000..112e4195
--- /dev/null
+++ b/src/libs/conditions/secondary/USStateOfResidenceCondition.sol
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./SecondaryTradingConditionBase.sol";
+import "../../auth.sol";
+import "../../../interfaces/ILexChexBadge.sol";
+import {Offer} from "../../../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title USStateOfResidenceCondition - blue-sky state gating for U.S. acceptors (§6.9, Addendum D)
+/// @author MetaLeX Labs, Inc.
+/// @notice Shared deployment with per-SPV configuration: a blocked-states list keyed by SPV, adjustable
+/// by the GP via a setter gated on the SPV's own BorgAuth admin. New York defaults onto the list for any
+/// SPV without Martin Act registration; typical additions are Alabama, Kentucky, Virginia for SPVs
+/// expecting only §4(a)(1½)/Rule 144 trades. Reads the state-of-residence (individuals) or
+/// state-of-organization (entities) attribute on the acquirer's LeXcheXBadge credential (§4.1.3A).
+/// Silent for non-U.S. acceptors (no usState attribute) and for U.S. acceptors whose state is not listed.
+contract USStateOfResidenceCondition is SecondaryTradingConditionBase, UUPSUpgradeable, BorgAuthACL {
+ error InvalidBadge();
+ error InvalidSpv();
+
+ event BadgeUpdated(address badge);
+ event StateBlocked(address indexed spv, bytes2 state, bool blocked);
+ event MartinActRegistrationUpdated(address indexed spv, bool registered);
+
+ bytes2 public constant NEW_YORK = "NY";
+
+ ILexChexBadge public badge;
+
+ // spv (cyberCORP address) => two-letter state code => blocked
+ mapping(address => mapping(bytes2 => bool)) public blockedStates;
+ // spv => registered under NY Martin Act Article 23-A (clears the NY default block)
+ mapping(address => bool) public martinActRegistered;
+
+ uint256[46] private __gap;
+
+ /// @custom:oz-upgrades-unsafe-allow constructor
+ constructor() {
+ _disableInitializers();
+ }
+
+ function initialize(address _auth, address _badge) public initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ function updateBadge(address _badge) external onlyAdmin {
+ if (_badge == address(0)) revert InvalidBadge();
+ badge = ILexChexBadge(_badge);
+ emit BadgeUpdated(_badge);
+ }
+
+ /// @notice Adds/removes a state on an SPV's blocked list; only the SPV's own BorgAuth admin (the GP)
+ function setStateBlocked(address spv, bytes2 state, bool blocked) external {
+ if (spv == address(0)) revert InvalidSpv();
+ _requireAuthAdmin(spv);
+ blockedStates[spv][state] = blocked;
+ emit StateBlocked(spv, state, blocked);
+ }
+
+ /// @notice Records the SPV's Martin Act registration status; unregistered SPVs block NY by default
+ function setMartinActRegistered(address spv, bool registered) external {
+ if (spv == address(0)) revert InvalidSpv();
+ _requireAuthAdmin(spv);
+ martinActRegistered[spv] = registered;
+ emit MartinActRegistrationUpdated(spv, registered);
+ }
+
+ /// @notice Effective block status for a state, including the NY Martin Act default
+ function isStateBlocked(address spv, bytes2 state) public view returns (bool) {
+ if (blockedStates[spv][state]) return true;
+ if (state == NEW_YORK && !martinActRegistered[spv]) return true;
+ return false;
+ }
+
+ function checkCondition(
+ IDealManager dealManager,
+ bytes4,
+ bytes32 offerId,
+ bytes32 agreementId
+ ) external view override returns (bool) {
+ Offer memory offer = dealManager.getOffer(offerId);
+ (, address buyer,) = _resolveParties(dealManager, offer, agreementId);
+
+ // No acquirer yet (posting context) — nothing to gate
+ if (buyer == address(0)) return true;
+
+ // Non-U.S. acceptors carry no usState attribute (enforced at badge mint): silent
+ bytes2 state = badge.getUsState(buyer);
+ if (state == bytes2(0)) return true;
+
+ return !isStateBlocked(offer.spvAddress, state);
+ }
+
+ function _authorizeUpgrade(address) internal override onlyOwner {}
+}
diff --git a/src/storage/CyberCertPrinterStorage.sol b/src/storage/CyberCertPrinterStorage.sol
index 99e35817..ca8d8dc6 100644
--- a/src/storage/CyberCertPrinterStorage.sol
+++ b/src/storage/CyberCertPrinterStorage.sol
@@ -42,46 +42,53 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
import "../CyberCorpConstants.sol";
+import {
+ CertificateDetails,
+ Endorsement,
+ ICyberCertPrinter,
+ OwnerDetails,
+ RestrictiveLegend,
+ RestrictionType
+} from "../interfaces/ICyberCertPrinter.sol";
import "../interfaces/ICyberCorp.sol";
import "../interfaces/IIssuanceManager.sol";
import "../interfaces/IUriBuilder.sol";
import "../interfaces/ITransferRestrictionHook.sol";
import "./extensions/ICertificateExtension.sol";
-struct CertificateDetails {
- string signingOfficerName;
- string signingOfficerTitle;
- uint256 investmentAmountUSD;
- uint256 issuerUSDValuationAtTimeOfInvestment;
- uint256 unitsRepresented;
- string legalDetails;
- bytes extensionData;
-}
-
-struct Endorsement {
- address endorser;
- uint256 timestamp;
- bytes signatureHash;
- address registry; //optional
- bytes32 agreementId; //optional
- address endorsee;
- string endorseeName;
-}
-
-struct OwnerDetails {
- string name;
- address ownerAddress;
-}
-
library CyberCertPrinterStorage {
// Storage slot for our struct
bytes32 constant STORAGE_POSITION = keccak256("cybercorp.cert.printer.storage.v1");
+ // Mirrors of the printer's error/event signatures; identical selectors/topics,
+ // so reverts and logs surface exactly as if they came from the printer (delegatecall).
+ error TokenNotTransferable();
+ error TransferRestricted(string reason);
+ error EndorsementNotSignedOrInvalid();
+ error InvalidLegendIndex();
+ error ExceedsAvailableUnits();
+ error ExceedsReservedUnits();
+
+ event CertificateAssigned(uint256 indexed tokenId, address indexed newOwner, string newOwnerName, string issuerName);
+ event CyberCertPrinter_CertificateCreated(uint256 indexed tokenId);
+ event UnitsReservedUpdated(uint256 indexed tokenId, uint256 unitsReserved);
+ event CertificateEndorsed(
+ uint256 indexed tokenId,
+ address indexed endorser,
+ address indexed endorsee,
+ string endorseeName,
+ address registry,
+ bytes32 agreementId,
+ uint256 index,
+ uint256 timestamp
+ );
+
// Main storage layout struct
struct CyberCertStorage {
// Token data
mapping(uint256 => CertificateDetails) certificateDetails;
mapping(uint256 => Endorsement[]) endorsements;
+ // use `_setLegalOwner()` to set. DO NOT set `owners[tokenId]` directly or otherwise its indexes would break
mapping(uint256 => OwnerDetails) owners;
mapping(uint256 => SecurityStatus) securityStatus;
mapping(uint256 => string[]) certLegend;
@@ -100,13 +107,24 @@ library CyberCertPrinterStorage {
// New variables must be appended below to preserve storage layout for upgrades
mapping(uint256 => bool) tokenTransferable;
mapping(uint256 => bytes[]) issuerSignatures;
-
+ // Units locked in a pending deal/loan; always <= certificateDetails[tokenId].unitsRepresented
+ mapping(uint256 => uint256) unitsReserved;
+ mapping(uint256 => RestrictiveLegend[]) certLegendsV2;
+ RestrictiveLegend[] defaultLegendsV2;
+ mapping(address => uint256) holderTokenCount;
+ uint256 uniqueHolderCount;
+
+ // ERC721Enumerable-like implementation for legal owners
+ mapping(address => mapping(uint256 => uint256)) legalOwnedTokens; // owner => index => tokenId
+ mapping(uint256 => uint256) legalOwnedTokensIndex; // tokenId => index within its owner's list
+ mapping(address => uint256) legalOwnerTokenCount; // owner => number of certs held of record
+ mapping(uint256 => bool) legalOwnedTokenTracked; // token is present in its legal owner's enumeration
}
// Returns the storage layout
function cyberCertStorage() internal pure returns (CyberCertStorage storage s) {
bytes32 position = STORAGE_POSITION;
- assembly {
+ assembly ("memory-safe") {
s.slot := position
}
}
@@ -114,7 +132,7 @@ library CyberCertPrinterStorage {
// URI storage functionality
function tokenURI(uint256 tokenId) external view returns (string memory) {
CyberCertPrinterStorage.CyberCertStorage storage s = cyberCertStorage();
- string[] memory certLegend = s.certLegend[tokenId];
+ RestrictiveLegend[] memory certLegend = getEffectiveRestrictiveLegends(tokenId);
ICyberCorp corp = ICyberCorp(IIssuanceManager(s.issuanceManager).CORP());
CertificateDetails memory effectiveDetails = getCertificateDetails(
tokenId
@@ -149,6 +167,320 @@ library CyberCertPrinterStorage {
);
}
+ /// @dev Transfer-time restriction and endorsement logic, extracted from
+ /// CyberCertPrinter._update to reduce the printer's bytecode size.
+ /// External so it runs via delegatecall against this deployed library.
+ /// Only called for true transfers (from != 0 && to != 0).
+ function processTransfer(address from, address to, uint256 tokenId) external {
+ CyberCertStorage storage s = cyberCertStorage();
+
+ // Check built-in transferability flag and per-token override
+ if (!s.transferable && !s.tokenTransferable[tokenId]) {
+ ICyberCorp corp = ICyberCorp(IIssuanceManager(s.issuanceManager).CORP());
+ if (from != corp.dealManager() && from != corp.roundManager()) revert TokenNotTransferable();
+ }
+
+ // Check global hook if it exists
+ if (address(s.globalRestrictionHook) != address(0)) {
+ (bool allowed, string memory reason) = s.globalRestrictionHook.checkTransferRestriction(
+ from, to, tokenId, ""
+ );
+ if (!allowed) revert TransferRestricted(reason);
+ }
+
+ ITransferRestrictionHook typeHook = CyberCertPrinterStorage.cyberCertStorage().restrictionHooksById[tokenId];
+
+ if (address(typeHook) != address(0)) {
+ (bool allowed, string memory reason) = typeHook.checkTransferRestriction(
+ from, to, tokenId, ""
+ );
+ if (!allowed) revert TransferRestricted(reason);
+ }
+
+ address ownerAddress = s.owners[tokenId].ownerAddress;
+ uint256 endorsementCount = s.endorsements[tokenId].length;
+ //check endorsement and update owners
+ if (from == ownerAddress) {
+ if (!s.endorsementRequired) {
+ emit CertificateAssigned(tokenId, to, "", IIssuanceManager(s.issuanceManager).companyName());
+ _setLegalOwner(s, tokenId, to, "");
+ }
+ else if (endorsementCount > 0) {
+ Endorsement memory endorsement = s.endorsements[tokenId][endorsementCount - 1];
+ if (endorsement.endorsee == to) {
+ // Endorsement exists; ownership will be updated
+ emit CertificateAssigned(tokenId, to, endorsement.endorseeName, IIssuanceManager(s.issuanceManager).companyName());
+ _setLegalOwner(s, tokenId, endorsement.endorsee, endorsement.endorseeName);
+ }
+ }
+ // NOTE: we don't revert in this block: Owner is able to transfer to another address without an endorsement, but it does not update the owner
+ }
+ else if (endorsementCount > 0) {
+ // Token is not being transferred from the current owner. It can only be transferrred to the latest endorsee, or the current owner
+ Endorsement memory endorsement = s.endorsements[tokenId][endorsementCount - 1];
+ if (endorsement.endorsee != to && ownerAddress != to) revert EndorsementNotSignedOrInvalid();
+
+ emit CertificateAssigned(tokenId, to, endorsement.endorseeName, IIssuanceManager(s.issuanceManager).companyName());
+ _setLegalOwner(s, tokenId, endorsement.endorsee, endorsement.endorseeName);
+ }
+ else revert EndorsementNotSignedOrInvalid();
+ }
+
+ /// @dev Post-mint bookkeeping for CyberCertPrinter.safeMint (the _safeMint itself stays in the printer).
+ function recordMint(uint256 tokenId, address to, CertificateDetails memory details) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ s.certLegend[tokenId] = s.defaultLegend;
+ copyDefaultRestrictiveLegendsToCert(s, tokenId);
+ s.certificateDetails[tokenId] = details;
+ _setLegalOwner(s, tokenId, to, "");
+ emit CyberCertPrinter_CertificateCreated(tokenId);
+ }
+
+ /// @dev Post-mint bookkeeping for CyberCertPrinter.safeMintAndAssign.
+ function recordMintAndAssign(
+ uint256 tokenId,
+ address to,
+ CertificateDetails memory details,
+ string memory investorName
+ ) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ s.certLegend[tokenId] = s.defaultLegend;
+ copyDefaultRestrictiveLegendsToCert(s, tokenId);
+ s.certificateDetails[tokenId] = details;
+ _setLegalOwner(s, tokenId, to, investorName);
+ emit CertificateAssigned(tokenId, to, investorName, IIssuanceManager(s.issuanceManager).companyName());
+ emit CyberCertPrinter_CertificateCreated(tokenId);
+ }
+
+ /// @dev Bookkeeping for CyberCertPrinter.assignCert (the ownerOf check stays in the printer).
+ function recordAssign(uint256 tokenId, address to, CertificateDetails memory details) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ s.certificateDetails[tokenId] = details;
+ _setLegalOwner(s, tokenId, to, "");
+ emit CertificateAssigned(tokenId, to, "", IIssuanceManager(s.issuanceManager).companyName());
+ }
+
+ /// @dev Endorsement push + event for CyberCertPrinter.addEndorsement (the auth check stays in the printer).
+ function recordEndorsement(uint256 tokenId, Endorsement memory newEndorsement) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ s.endorsements[tokenId].push(newEndorsement);
+ emit CertificateEndorsed(
+ tokenId,
+ newEndorsement.endorser,
+ newEndorsement.endorsee,
+ newEndorsement.endorseeName,
+ newEndorsement.registry,
+ newEndorsement.agreementId,
+ s.endorsements[tokenId].length - 1,
+ block.timestamp
+ );
+ }
+
+ /// @dev Single chokepoint for every owners[] write: reassign tokenId's legal owner to `newOwner`
+ /// (holder-of-record name `name`) while keeping the per-legal-owner enumeration in sync.
+ function _setLegalOwner(CyberCertStorage storage s, uint256 tokenId, address newOwner, string memory name) private {
+ address current = s.owners[tokenId].ownerAddress;
+ // Always end with tokenId tracked under newOwner. The add is idempotent and the remove is a no-op for
+ // un-tracked tokens, so this both maintains live state and lazily backfills a legacy token (one minted
+ // before this enumeration existed) the moment any owner-write touches it.
+ if (current != newOwner && current != address(0)) {
+ _removeFromLegalOwnerEnumeration(s, current, tokenId);
+ }
+ if (newOwner != address(0)) {
+ _addToLegalOwnerEnumeration(s, newOwner, tokenId);
+ }
+ s.owners[tokenId] = OwnerDetails(name, newOwner);
+ }
+
+ /// @dev Idempotent: a token already in an owner's enumeration is left alone (so backfilling a tracked token
+ /// is a no-op and we never double-count).
+ function _addToLegalOwnerEnumeration(CyberCertStorage storage s, address owner, uint256 tokenId) private {
+ if (s.legalOwnedTokenTracked[tokenId]) return;
+ uint256 index = s.legalOwnerTokenCount[owner];
+ s.legalOwnedTokens[owner][index] = tokenId;
+ s.legalOwnedTokensIndex[tokenId] = index;
+ s.legalOwnerTokenCount[owner] = index + 1;
+ s.legalOwnedTokenTracked[tokenId] = true;
+ }
+
+ /// @dev Swap-and-pop removal, mirroring OZ ERC721Enumerable's _removeTokenFromOwnerEnumeration. No-op for an
+ /// un-tracked token — this is what keeps a legacy printer's first owner-change/burn from underflowing
+ /// `legalOwnerTokenCount` (which is 0 until the token is backfilled).
+ function _removeFromLegalOwnerEnumeration(CyberCertStorage storage s, address owner, uint256 tokenId) private {
+ if (!s.legalOwnedTokenTracked[tokenId]) return;
+ uint256 lastIndex = s.legalOwnerTokenCount[owner] - 1;
+ uint256 tokenIndex = s.legalOwnedTokensIndex[tokenId];
+ if (tokenIndex != lastIndex) {
+ uint256 lastTokenId = s.legalOwnedTokens[owner][lastIndex];
+ s.legalOwnedTokens[owner][tokenIndex] = lastTokenId;
+ s.legalOwnedTokensIndex[lastTokenId] = tokenIndex;
+ }
+ delete s.legalOwnedTokensIndex[tokenId];
+ delete s.legalOwnedTokens[owner][lastIndex];
+ s.legalOwnerTokenCount[owner] = lastIndex;
+ s.legalOwnedTokenTracked[tokenId] = false;
+ }
+
+ /// @dev Drop tokenId from its legal owner's enumeration on burn — no transfer hook fires for to==0,
+ /// so the printer calls this explicitly.
+ function recordBurnLegalOwner(uint256 tokenId) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ address owner = s.owners[tokenId].ownerAddress;
+ if (owner != address(0)) _removeFromLegalOwnerEnumeration(s, owner, tokenId);
+ delete s.owners[tokenId];
+ }
+
+ /// @dev One-time/idempotent migration for printers deployed before the legal-owner enumeration existed:
+ /// adds each live token in [startIndex, startIndex+count) to its legal owner's enumeration. Permissionless
+ /// and safe to re-run — already-tracked tokens are skipped. Call in batches over [0, totalSupply()).
+ function backfillLegalOwnerEnumeration(uint256 startIndex, uint256 count) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ ICyberCertPrinter self = ICyberCertPrinter(address(this)); // delegatecalled: address(this) is the printer
+ uint256 supply = self.totalSupply();
+ uint256 end = startIndex + count;
+ if (end > supply) end = supply;
+ for (uint256 i = startIndex; i < end; i++) {
+ uint256 tokenId = self.tokenByIndex(i); // enumerates live tokens only (burned are excluded)
+ address owner = s.owners[tokenId].ownerAddress;
+ if (owner != address(0)) _addToLegalOwnerEnumeration(s, owner, tokenId);
+ }
+ }
+
+ /// @dev Reserve units against a pending deal/loan. Reverts if the total reserved
+ /// would exceed the certificate's units.
+ function increaseUnitsReserved(uint256 tokenId, uint256 amount) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ uint256 newReserved = s.unitsReserved[tokenId] + amount;
+ if (newReserved > s.certificateDetails[tokenId].unitsRepresented) revert ExceedsAvailableUnits();
+ s.unitsReserved[tokenId] = newReserved;
+ emit UnitsReservedUpdated(tokenId, newReserved);
+ }
+
+ /// @dev Release previously reserved units. Reverts if releasing more than is reserved.
+ function decreaseUnitsReserved(uint256 tokenId, uint256 amount) external {
+ CyberCertStorage storage s = cyberCertStorage();
+ uint256 reserved = s.unitsReserved[tokenId];
+ if (amount > reserved) revert ExceedsReservedUnits();
+ uint256 newReserved;
+ unchecked { newReserved = reserved - amount; }
+ s.unitsReserved[tokenId] = newReserved;
+ emit UnitsReservedUpdated(tokenId, newReserved);
+ }
+
+ function getUnitsReserved(uint256 tokenId) internal view returns (uint256) {
+ return cyberCertStorage().unitsReserved[tokenId];
+ }
+
+ function recordHolderChange(address from, address to) internal {
+ CyberCertStorage storage s = cyberCertStorage();
+
+ if (from == to) return;
+
+ if (from != address(0)) {
+ uint256 fromBalance = s.holderTokenCount[from] - 1;
+ s.holderTokenCount[from] = fromBalance;
+ if (fromBalance == 0) {
+ s.uniqueHolderCount--;
+ }
+ }
+
+ if (to != address(0)) {
+ uint256 toBalance = s.holderTokenCount[to];
+ if (toBalance == 0) {
+ s.uniqueHolderCount++;
+ }
+ s.holderTokenCount[to] = toBalance + 1;
+ }
+ }
+
+ function getHolderCount() internal view returns (uint256) {
+ return cyberCertStorage().uniqueHolderCount;
+ }
+
+ // Legend management; isDefault selects the defaultLegend array (tokenId ignored) vs a cert's legend
+ function _legendArray(uint256 tokenId, bool isDefault) private view returns (string[] storage) {
+ CyberCertStorage storage s = cyberCertStorage();
+ if (isDefault) return s.defaultLegend;
+ return s.certLegend[tokenId];
+ }
+
+ function addLegend(uint256 tokenId, bool isDefault, string memory newLegend) external {
+ _legendArray(tokenId, isDefault).push(newLegend);
+ }
+
+ function removeLegendAt(uint256 tokenId, bool isDefault, uint256 index) external {
+ string[] storage arr = _legendArray(tokenId, isDefault);
+ uint256 len = arr.length;
+ if (index >= len) revert InvalidLegendIndex();
+
+ // Move the last element to the index being removed (if it's not the last element)
+ // and then pop the last element
+ uint256 lastIndex = len - 1;
+ if (index != lastIndex) {
+ string memory lastLegend = arr[lastIndex];
+ arr[index] = lastLegend;
+ }
+ arr.pop();
+ }
+
+ function _restrictiveLegendArray(uint256 tokenId, bool isDefault) private view returns (RestrictiveLegend[] storage) {
+ CyberCertStorage storage s = cyberCertStorage();
+ if (isDefault) return s.defaultLegendsV2;
+ return s.certLegendsV2[tokenId];
+ }
+
+ function copyDefaultRestrictiveLegendsToCert(CyberCertStorage storage s, uint256 tokenId) private {
+ delete s.certLegendsV2[tokenId];
+ RestrictiveLegend[] storage certLegends = s.certLegendsV2[tokenId];
+ for (uint256 i = 0; i < s.defaultLegendsV2.length; i++) {
+ RestrictiveLegend memory legend = s.defaultLegendsV2[i];
+ certLegends.push(legend);
+ }
+ }
+
+ function addRestrictiveLegend(uint256 tokenId, bool isDefault, RestrictiveLegend memory newLegend) external {
+ _restrictiveLegendArray(tokenId, isDefault).push(newLegend);
+ }
+
+ function removeRestrictiveLegendAt(uint256 tokenId, bool isDefault, uint256 index) external {
+ RestrictiveLegend[] storage arr = _restrictiveLegendArray(tokenId, isDefault);
+ uint256 len = arr.length;
+ if (index >= len) revert InvalidLegendIndex();
+
+ uint256 lastIndex = len - 1;
+ if (index != lastIndex) {
+ arr[index] = arr[lastIndex];
+ }
+ arr.pop();
+ }
+
+ function getEffectiveRestrictiveLegends(uint256 tokenId) internal view returns (RestrictiveLegend[] memory legends) {
+ CyberCertStorage storage s = cyberCertStorage();
+ if (s.certLegendsV2[tokenId].length > 0) {
+ RestrictiveLegend[] storage storedLegends = s.certLegendsV2[tokenId];
+ legends = new RestrictiveLegend[](storedLegends.length);
+ for (uint256 i = 0; i < storedLegends.length; i++) {
+ legends[i] = storedLegends[i];
+ }
+ } else {
+ string[] storage legacyLegends = s.certLegend[tokenId];
+ legends = new RestrictiveLegend[](legacyLegends.length);
+ for (uint256 i = 0; i < legacyLegends.length; i++) {
+ legends[i] = RestrictiveLegend({
+ restrictionType: RestrictionType.Custom,
+ title: "",
+ text: legacyLegends[i],
+ jurisdiction: "",
+ referenceId: bytes32(0),
+ effectiveTimestamp: 0,
+ expirationTimestamp: 0,
+ active: true,
+ data: ""
+ });
+ }
+ }
+ }
+
// Internal getters for complex types
function getStoredCertificateDetails(uint256 tokenId) internal view returns (CertificateDetails storage) {
return cyberCertStorage().certificateDetails[tokenId];
@@ -202,7 +534,7 @@ library CyberCertPrinterStorage {
}
function setOwnerDetails(uint256 tokenId, OwnerDetails memory details) internal {
- cyberCertStorage().owners[tokenId] = details;
+ _setLegalOwner(cyberCertStorage(), tokenId, details.ownerAddress, details.name);
}
function setSecurityStatus(uint256 tokenId, SecurityStatus status) internal {
diff --git a/src/storage/DealManagerFactoryStorage.sol b/src/storage/DealManagerFactoryStorage.sol
index 956a2a85..b58d486b 100644
--- a/src/storage/DealManagerFactoryStorage.sol
+++ b/src/storage/DealManagerFactoryStorage.sol
@@ -51,6 +51,12 @@ library DealManagerFactoryStorage {
uint256 public constant BASIS_POINTS = 10000; // 100%
+ /// @notice A whitelisted integrator and its share of the protocol fee (spec §12B.4)
+ struct Integrator {
+ bool approved; // whitelist membership
+ uint256 feeShare; // share of the protocol fee (BASIS_POINTS = 100%)
+ }
+
/// @notice Main storage layout struct that holds all persisted data
/// @dev Uses unstructured storage pattern to avoid storage collisions
struct DealManagerFactoryData {
@@ -58,6 +64,9 @@ library DealManagerFactoryStorage {
address platformPayable; // Recipient of platform fees
uint256 defaultFeeRatio; // total fee as % of ticket size (BASIS_POINTS = 100%)
+ // Per-integrator fee split (spec §12B.4): each whitelisted integrator earns its own share
+ // of the protocol fee, the rest going to the platform.
+ mapping(address => Integrator) integrators;
}
/// @notice Retrieves the storage reference for the DealManagerFactoryData struct
@@ -93,4 +102,12 @@ library DealManagerFactoryStorage {
function setDefaultFeeRatio(uint256 feeRatio) internal {
dealManagerFactoryStorage().defaultFeeRatio = feeRatio;
}
+
+ function getIntegrator(address integrator) internal view returns (Integrator memory) {
+ return dealManagerFactoryStorage().integrators[integrator];
+ }
+
+ function setIntegrator(address integrator, bool approved, uint256 feeShare) internal {
+ dealManagerFactoryStorage().integrators[integrator] = Integrator(approved, feeShare);
+ }
}
diff --git a/src/storage/DealManagerStorage.sol b/src/storage/DealManagerStorage.sol
index 8bd45641..9b81898f 100644
--- a/src/storage/DealManagerStorage.sol
+++ b/src/storage/DealManagerStorage.sol
@@ -41,15 +41,47 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
+import "openzeppelin-contracts/token/ERC20/IERC20.sol";
+import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
+import "openzeppelin-contracts/token/ERC721/IERC721.sol";
+import "openzeppelin-contracts/token/ERC1155/IERC1155.sol";
import "../interfaces/IIssuanceManager.sol";
+import "../interfaces/ICyberAgreementRegistry.sol";
+import "../interfaces/ICyberCorp.sol";
+import "../interfaces/ICyberCertPrinter.sol";
+import "../interfaces/IDealManagerFactory.sol";
+import "../interfaces/ICondition.sol";
+import "../CyberCorpConstants.sol";
+import "./DealManagerFactoryStorage.sol";
+import {LexScrowStorage, Escrow, Token, TokenType, EscrowStatus} from "./LexScrowStorage.sol";
+import {IDealManagerStorage} from "../interfaces/IDealManagerStorage.sol";
+import {ILexScrowStorage} from "../interfaces/ILexScrowStorage.sol";
/// @title DealManagerStorage
-/// @notice Storage library for the DealManager contract that handles persistent data storage
-/// @dev Uses the unstructured storage pattern to manage deal-related data
+/// @notice Storage library + legacy deal lifecycle logic (propose / sign / finalize / void) for DealManager.
+/// @dev Uses the unstructured storage pattern to manage deal-related data. The logic functions are `public`
+/// so the library is deployed separately and linked; DealManager calls them via DELEGATECALL (msg.sender /
+/// storage context preserved), keeping that logic out of DealManager's bytecode (EIP-170).
+/// `proposeAndSignDeal` / `proposeAndSignNewCertsDeal` deliberately live in DealManager (not here):
+/// keeping `proposeAndSignDeal` out of this library stops the via-ir Yul optimizer from inlining `proposeDeal`
+/// into it and cause stack overflow.
library DealManagerStorage {
+ using SafeERC20 for IERC20;
+
// Storage slot for our struct
bytes32 constant STORAGE_POSITION = keccak256("cybercorp.deal.manager.storage.v1");
+ /// @notice Certificate data structure for creating new certificates
+ struct CyberCertData {
+ string name;
+ string symbol;
+ string uri;
+ SecurityClass securityClass;
+ SecuritySeries securitySeries;
+ address extension;
+ string[] defaultLegend;
+ }
+
/// @notice Main storage layout struct that holds all deal manager data
/// @dev Uses unstructured storage pattern to avoid storage collisions
struct DealManagerData {
@@ -108,4 +140,226 @@ library DealManagerStorage {
function getUpgradeFactory() internal view returns (address) {
return dealManagerStorage().upgradeFactory;
}
-}
\ No newline at end of file
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Legacy deal proposal (linked logic; called via delegatecall)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Proposes a new deal: creates the agreement + certificates and sets up the escrow
+ /// @dev Access control (onlyOwner) is enforced by the DealManager wrapper that delegatecalls here.
+ function proposeDeal(
+ address[] memory _certPrinterAddress,
+ address _paymentToken,
+ uint256 _paymentAmount,
+ bytes32 _templateId,
+ uint256 _salt,
+ string[] memory _globalValues,
+ address[] memory _parties,
+ CertificateDetails[] memory _certDetails,
+ string[][] memory _partyValues,
+ address[] memory conditions,
+ bytes32 secretHash,
+ uint256 expiry
+ ) public returns (bytes32 agreementId, uint256[] memory certIds) {
+ agreementId = ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).createContract(_templateId, _salt, _globalValues, _parties, _partyValues, secretHash, address(this), expiry);
+
+ Token[] memory corpAssets = new Token[](_certDetails.length);
+ certIds = new uint256[](_certDetails.length);
+ for(uint256 i = 0; i < _certDetails.length; i++) {
+ certIds[i] = getIssuanceManager().createCert(_certPrinterAddress[i], address(this), _certDetails[i]);
+ corpAssets[i] = Token(TokenType.ERC721, _certPrinterAddress[i], certIds[i], 1, false);
+ }
+
+ Token[] memory buyerAssets = new Token[](1);
+ buyerAssets[0] = Token(TokenType.ERC20, _paymentToken, 0, _paymentAmount, true); // Will be used as fee token
+
+ Escrow memory newEscrow = Escrow({
+ agreementId: agreementId,
+ counterParty: _parties[1],
+ corpAssets: corpAssets,
+ buyerAssets: buyerAssets,
+ signature: "",
+ expiry: expiry,
+ status: EscrowStatus.PENDING
+ });
+
+ LexScrowStorage.setEscrow(agreementId, newEscrow);
+
+ //set conditions
+ for(uint256 i = 0; i < conditions.length; i++) {
+ LexScrowStorage.addConditionToEscrow(agreementId, ICondition(conditions[i]));
+ }
+
+ emit IDealManagerStorage.DealProposed(
+ agreementId,
+ _certPrinterAddress,
+ certIds,
+ _paymentToken,
+ _paymentAmount,
+ _templateId,
+ LexScrowStorage.getCorp(),
+ LexScrowStorage.getDealRegistry(),
+ _parties,
+ conditions,
+ secretHash > 0
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Deal lifecycle (linked logic; called via delegatecall)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Signs a deal and processes payment
+ /// @dev Access modifiers (if any) are carried by the DealManager wrapper that delegatecalls here.
+ function signDealAndPay(
+ address signer,
+ bytes32 agreementId,
+ bytes memory signature,
+ string[] memory partyValues,
+ bool _fillUnallocated,
+ string memory name,
+ string memory secret
+ ) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+ address registry = LexScrowStorage.getDealRegistry();
+ if(ICyberAgreementRegistry(registry).isVoided(agreementId)) revert LexScrowStorage.DealVoided();
+ if(ICyberAgreementRegistry(registry).isFinalized(agreementId)) revert LexScrowStorage.DealAlreadyFinalized();
+ Escrow storage escrow = LexScrowStorage.getEscrow(agreementId);
+ if(escrow.status != EscrowStatus.PENDING) revert IDealManagerStorage.DealNotPending();
+ if(escrow.expiry < block.timestamp) revert LexScrowStorage.DealExpired();
+
+ string[] storage counterPartyCheck = getCounterPartyValues(agreementId);
+ if(counterPartyCheck.length > 0) {
+ if (keccak256(abi.encode(counterPartyCheck)) != keccak256(abi.encode(partyValues))) revert IDealManagerStorage.CounterPartyValueMismatch();
+ }
+ else {
+ setCounterPartyValues(agreementId, partyValues);
+ }
+
+ ICyberAgreementRegistry(registry).signContractFor(signer, agreementId, partyValues, signature, _fillUnallocated, secret);
+ LexScrowStorage.updateEscrow(agreementId, signer, name);
+ LexScrowStorage.handleCounterPartyPayment(agreementId);
+ }
+
+ /// @notice Signs and finalizes a deal in one call
+ /// @dev Access modifiers (if any) are carried by the DealManager wrapper that delegatecalls here.
+ function signAndFinalizeDeal(
+ address signer,
+ bytes32 agreementId,
+ string[] memory partyValues,
+ bytes memory signature,
+ bool _fillUnallocated,
+ string memory name,
+ string memory secret
+ ) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+ address registry = LexScrowStorage.getDealRegistry();
+ if(ICyberAgreementRegistry(registry).isVoided(agreementId)) revert LexScrowStorage.DealVoided();
+ if(ICyberAgreementRegistry(registry).isFinalized(agreementId)) revert LexScrowStorage.DealAlreadyFinalized();
+ if(LexScrowStorage.getEscrow(agreementId).status != EscrowStatus.PENDING) revert IDealManagerStorage.DealNotPending();
+
+ string[] storage counterPartyCheck = getCounterPartyValues(agreementId);
+ if(counterPartyCheck.length > 0) {
+ if (keccak256(abi.encode(counterPartyCheck)) != keccak256(abi.encode(partyValues))) revert IDealManagerStorage.CounterPartyValueMismatch();
+ } else {
+ setCounterPartyValues(agreementId, partyValues);
+ }
+
+ if (!ICyberAgreementRegistry(registry).hasSigned(agreementId, signer)) {
+ // Not signed in registry yet; enforce local consistency and then sign
+ ICyberAgreementRegistry(registry).signContractFor(signer, agreementId, partyValues, signature, _fillUnallocated, secret);
+ } else {
+ // Already signed in registry; fetch values recorded in the registry and ensure consistency
+ string[] memory registryValues = ICyberAgreementRegistry(registry).getSignerValues(agreementId, signer);
+ if (keccak256(abi.encode(registryValues)) != keccak256(abi.encode(partyValues))) revert IDealManagerStorage.CounterPartyValueMismatch();
+ }
+
+ LexScrowStorage.updateEscrow(agreementId, signer, name);
+ if(!LexScrowStorage.conditionCheck(agreementId)) revert ILexScrowStorage.AgreementConditionsNotMet();
+ LexScrowStorage.handleCounterPartyPayment(agreementId);
+ finalizeDeal(agreementId);
+ }
+
+ /// @notice Finalizes a primary deal (checks signatures/conditions, settles escrow)
+ /// @dev nonReentrant is carried by the DealManager wrapper that delegatecalls here.
+ function finalizeDeal(bytes32 agreementId) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+
+ address registry = LexScrowStorage.getDealRegistry();
+ if (ICyberAgreementRegistry(registry).isVoided(agreementId)) revert LexScrowStorage.DealVoided();
+ if (ICyberAgreementRegistry(registry).isFinalized(agreementId)) revert LexScrowStorage.DealAlreadyFinalized();
+ if (!ICyberAgreementRegistry(registry).allPartiesSigned(agreementId)) revert LexScrowStorage.DealNotFullySigned();
+
+ if (LexScrowStorage.getEscrow(agreementId).status != EscrowStatus.PAID) revert LexScrowStorage.DealNotPaid();
+ if (!LexScrowStorage.conditionCheck(agreementId)) revert ILexScrowStorage.AgreementConditionsNotMet();
+ ICyberAgreementRegistry(registry).finalizeContract(agreementId);
+ LexScrowStorage.finalizeEscrow(agreementId);
+
+ emit IDealManagerStorage.DealFinalized(
+ agreementId,
+ msg.sender,
+ LexScrowStorage.getCorp(),
+ registry,
+ false
+ );
+ }
+
+ /// @notice Voids an expired primary deal
+ /// @dev nonReentrant is carried by the DealManager wrapper that delegatecalls here.
+ function voidExpiredDeal(bytes32 agreementId, address signer, bytes memory signature) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+
+ address registry = LexScrowStorage.getDealRegistry();
+ Escrow storage deal = LexScrowStorage.getEscrow(agreementId);
+ if (block.timestamp <= deal.expiry) revert IDealManagerStorage.DealNotExpired();
+ ICyberAgreementRegistry(registry).voidContractFor(agreementId, signer, signature);
+ for (uint256 i = 0; i < deal.corpAssets.length; i++) {
+ if (deal.corpAssets[i].tokenType == TokenType.ERC721) {
+ getIssuanceManager().voidCertificate(
+ deal.corpAssets[i].tokenAddress,
+ deal.corpAssets[i].tokenId
+ );
+ }
+ }
+ if (deal.status == EscrowStatus.PAID)
+ // Interaction: payment
+ LexScrowStorage.voidAndRefund(agreementId);
+ else if (deal.status == EscrowStatus.PENDING)
+ // Effect: update status
+ LexScrowStorage.voidEscrow(agreementId);
+ }
+
+ /// @notice Revokes a pending deal
+ /// @dev Access modifiers (if any) are carried by the DealManager wrapper that delegatecalls here.
+ function revokeDeal(bytes32 agreementId, address signer, bytes memory signature) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+ if(msg.sender != signer) revert IDealManagerStorage.CounterPartyValueMismatch();
+ if(LexScrowStorage.getEscrow(agreementId).status == EscrowStatus.PENDING)
+ ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
+ else
+ revert IDealManagerStorage.DealNotPending();
+ }
+
+ /// @notice Signs to void a deal; refunds if the deal was paid
+ /// @dev nonReentrant is carried by the DealManager wrapper that delegatecalls here.
+ function signToVoid(bytes32 agreementId, address signer, bytes memory signature) public {
+ // Check: status
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+ if(msg.sender != signer) revert IDealManagerStorage.CounterPartyValueMismatch();
+
+ // Effect: update status
+ ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
+ if(ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId) && LexScrowStorage.getEscrow(agreementId).status == EscrowStatus.PAID)
+ // Interaction: payment
+ LexScrowStorage.voidAndRefund(agreementId);
+ }
+
+ /// @notice Refund a voided deal
+ /// @dev nonReentrant is carried by the DealManager wrapper that delegatecalls here.
+ function refundVoidedDeal(bytes32 agreementId) public {
+ if (!LexScrowStorage.hasPrimaryEscrow(agreementId)) revert LexScrowStorage.DealDoesNotExist();
+ // Interaction: Re-sync Deal Manager internal escrow to VOIDED, then refund
+ LexScrowStorage.voidAndRefund(agreementId);
+ }
+
+}
\ No newline at end of file
diff --git a/src/storage/IssuanceManagerStorage.sol b/src/storage/IssuanceManagerStorage.sol
index ec95edef..05a370a2 100644
--- a/src/storage/IssuanceManagerStorage.sol
+++ b/src/storage/IssuanceManagerStorage.sol
@@ -49,7 +49,9 @@ import "../interfaces/ICyberCertPrinter.sol";
import "../interfaces/ICyberCorp.sol";
import "../interfaces/ICyberScrip.sol";
import "../interfaces/IIssuanceManager.sol";
+import "../interfaces/IIssuanceManagerFactory.sol";
import "../interfaces/ITransferRestrictionHook.sol";
+import {ExemptionPathway, HostingMode} from "../interfaces/ISecondaryTradeStorage.sol";
import "./CyberCertPrinterStorage.sol";
library IssuanceManagerStorage {
@@ -232,6 +234,16 @@ library IssuanceManagerStorage {
return issuanceManagerStorage().printers;
}
+ /// @dev Linear membership scan over the printer registry. The list is admin-curated and small, and it is
+ /// the only authoritative source of printers created by this IssuanceManager (mirrors removePrinter).
+ function isPrinter(address printer) internal view returns (bool) {
+ address[] storage printers = issuanceManagerStorage().printers;
+ for (uint256 i = 0; i < printers.length; i++) {
+ if (printers[i] == printer) return true;
+ }
+ return false;
+ }
+
// Setters
function setCORP(address _corp) internal {
issuanceManagerStorage().CORP = _corp;
@@ -523,6 +535,40 @@ library IssuanceManagerStorage {
return _assetsOfVaultPosition(certAddress, id);
}
+ /// @notice Deploys the CyberCertPrinter and CyberScrip beacons and wires up core storage.
+ /// @dev Split out of IssuanceManager.initialize to keep that contract under the EIP-170 size
+ /// limit. Runs via delegatecall, so `address(this)` is the IssuanceManager and it owns the beacons.
+ function executeInitialize(
+ address upgradeFactory,
+ address corp,
+ address uriBuilder
+ ) external {
+ address cyberCertPrinterRefImpl = IIssuanceManagerFactory(
+ upgradeFactory
+ ).getCyberCertPrinterRefImplementation();
+ UpgradeableBeacon beaconCertPrinter = new UpgradeableBeacon(
+ cyberCertPrinterRefImpl,
+ address(this)
+ );
+ emit IIssuanceManager.CertPrinterBeaconImplementationUpgraded(
+ cyberCertPrinterRefImpl
+ );
+
+ address cyberScripRefImpl = IIssuanceManagerFactory(upgradeFactory)
+ .getCyberScripRefImplementation();
+ UpgradeableBeacon beaconScrip = new UpgradeableBeacon(
+ cyberScripRefImpl,
+ address(this)
+ );
+ emit IIssuanceManager.ScripBeaconImplementationUpgraded(cyberScripRefImpl);
+
+ setCORP(corp);
+ setUriBuilder(uriBuilder);
+ setCyberCertPrinterBeacon(beaconCertPrinter);
+ setUpgradeFactory(upgradeFactory);
+ setCyberScripBeacon(beaconScrip);
+ }
+
function executeCreateCertPrinter(
string[] memory ledger,
string memory name,
@@ -590,6 +636,7 @@ library IssuanceManagerStorage {
(cert, tokenId) = _mintAssignedCert(
certAddress,
investor,
+ investor, // primary issuance is always direct-hosted for now
details,
investorName
);
@@ -630,6 +677,7 @@ library IssuanceManagerStorage {
(cert, tokenId) = _mintAssignedCert(
certAddress,
investor,
+ investor, // primary issuance is always direct-hosted for now
details,
investorName
);
@@ -685,6 +733,109 @@ library IssuanceManagerStorage {
ICyberCertPrinter(certAddress).addEndorsement(tokenId, newEndorsement);
}
+ /// @notice Executes the secondary-trade ownership change at finalization (spec §7.4A steps a–d).
+ /// @dev Mutate-and-mint: the seller's Ledger Entry Token never moves wallets; ownership transfers via
+ /// metadata. Core scope — acquisitionDate / Rule 144(d)(3) tacking / per-pathway certLegend updates are
+ /// deferred (need a FundInterest extensionData format that does not exist yet), so exemptionPathway is
+ /// decoded for the record but otherwise unused here.
+ function executeSecondaryTransfer(bytes calldata dealMetadata)
+ external
+ returns (uint256 buyerTokenId)
+ {
+ (
+ address certPrinter,
+ uint256 tokenId,
+ uint256 units,
+ address buyer,
+ string memory buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig,
+ ,
+ bytes32 settlementAgreementId,
+ bytes memory openEndorsementSig
+ ) = abi.decode(
+ dealMetadata,
+ (address, uint256, uint256, address, string, HostingMode, address, ExemptionPathway, bytes32, bytes)
+ );
+
+ ICyberCertPrinter cert = ICyberCertPrinter(certPrinter);
+ // Registered owner of the seller's Ledger Entry Token, unchanged by hosting mode (the token never moves).
+ address seller = cert.legalOwnerOf(tokenId);
+
+ // (a) Materialize the seller's endorsement on the Ledger Entry Token. The seller signs in blank at
+ // posting/acceptance (spec §7.3.1) and that signature rides in dealMetadata; the endorsement is written
+ // here, at finalization, with the now-known buyer as endorsee (spec §7.4A step 1). Recorded while the
+ // token is still Assigned, before the void/decrement below. The seller is always the endorser of record
+ // (spec §3676-3680); the IssuanceManager is only the operational executor.
+ Endorsement memory sellerEndorsement = Endorsement({
+ endorser: seller,
+ timestamp: block.timestamp,
+ signatureHash: openEndorsementSig,
+ registry: address(0),
+ agreementId: settlementAgreementId,
+ endorsee: buyer,
+ endorseeName: buyerName
+ });
+ cert.addEndorsement(tokenId, sellerEndorsement);
+
+ // (b) Mutate the seller's Ledger Entry Token in place: decrement the sold units, then void if the
+ // token is fully sold (nothing left). Decrement-first so the struct carries no stale balance at void.
+ CertificateDetails memory sellerDetails = cert.getActiveCertificateDetails(tokenId);
+ if (units > sellerDetails.unitsRepresented) revert AmountExceedsAvailableUnits();
+ sellerDetails.unitsRepresented -= units;
+ cert.updateCertificateDetails(tokenId, sellerDetails);
+ bool sellerVoided = sellerDetails.unitsRepresented == 0;
+ if (sellerVoided) {
+ cert.voidCert(tokenId);
+ }
+ // (c) Deliver the buyer's units. By default we consolidate: if the buyer already holds an active
+ // (non-voided) Ledger Entry Token on this printer, fold the purchased units into it rather than
+ // fragmenting their position across one cert per fill; mint a fresh token only when they hold none.
+ // A printer is scoped to one security class/series, so consolidation never merges across security types
+ // (the folded units inherit the existing cert's terms). We look the buyer up by legal owner of record,
+ // so this is correct under both hosting modes — including Administered, where the multisig custodies the
+ // NFT but the buyer is the registered owner.
+ RecertSelection memory existing = _selectFirstLegalOwnedToken(certPrinter, buyer);
+ bool buyerTokenIsMinted = !existing.foundActive;
+ uint256 buyerUnitsAfter; // absolute post-mutation balance on the buyer token, reported in the event
+ if (existing.foundActive) {
+ // Fold the purchased units into the buyer's existing cert, leaving its basis fields
+ // (investmentAmountUSD / issuerUSDValuationAtTimeOfInvestment) unchanged: they stay a snapshot of
+ // that cert's primary issuance, regardless of how many secondary lots accumulate into it.
+ buyerTokenId = existing.activeTokenId;
+ CertificateDetails memory accDetails = cert.getActiveCertificateDetails(buyerTokenId);
+ accDetails.unitsRepresented += units;
+ cert.updateCertificateDetails(buyerTokenId, accDetails);
+ buyerUnitsAfter = accDetails.unitsRepresented;
+ } else {
+ // Mint a fresh token for the sold units. It inherits the seller's non-basis terms (signing officer,
+ // legalDetails, extensionData); cost basis stays blank since a secondary acquisition has no
+ // primary-issuance basis of its own. The custodian only decides where the NFT lands: the admin
+ // multisig under Administered hosting, otherwise the buyer (who is the legal owner either way).
+ address custodian = buyerHostingMode == HostingMode.ADMINISTERED ? adminMultisig : buyer;
+ CertificateDetails memory buyerDetails = CertificateDetails({
+ signingOfficerName: sellerDetails.signingOfficerName,
+ signingOfficerTitle: sellerDetails.signingOfficerTitle,
+ investmentAmountUSD: 0,
+ issuerUSDValuationAtTimeOfInvestment: 0,
+ unitsRepresented: units,
+ legalDetails: sellerDetails.legalDetails,
+ extensionData: sellerDetails.extensionData
+ });
+ (, buyerTokenId) = _mintAssignedCert(certPrinter, custodian, buyer, buyerDetails, buyerName);
+ buyerUnitsAfter = units;
+ }
+
+ // (d) Mirror the seller's endorsement onto the buyer's token: both tokens carry the identical
+ // chain-of-title record (endorser = seller, endorsee = buyer, this agreement), so reuse the (b) struct.
+ cert.addEndorsement(buyerTokenId, sellerEndorsement);
+
+ emit IIssuanceManager.SecondaryTransferExecuted(
+ settlementAgreementId, certPrinter, buyer, tokenId, buyerTokenId, seller, units,
+ sellerDetails.unitsRepresented, buyerUnitsAfter, sellerVoided, buyerTokenIsMinted
+ );
+ }
+
function executeVoidCertificate(address certAddress, uint256 tokenId) external {
ICyberCertPrinter(certAddress).voidCert(tokenId);
}
@@ -723,6 +874,22 @@ library IssuanceManagerStorage {
ICyberCertPrinter(certAddress).setTokenTransferable(tokenId, value);
}
+ function executeIncreaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external {
+ ICyberCertPrinter(certAddress).increaseUnitsReserved(tokenId, amount);
+ }
+
+ function executeDecreaseUnitsReserved(
+ address certAddress,
+ uint256 tokenId,
+ uint256 amount
+ ) external {
+ ICyberCertPrinter(certAddress).decreaseUnitsReserved(tokenId, amount);
+ }
+
function executeSetScripRatio(
address certAddress,
uint256 numerator,
@@ -789,6 +956,36 @@ library IssuanceManagerStorage {
ICyberCertPrinter(certAddress).removeCertLegendAt(tokenId, index);
}
+ function executeAddDefaultRestrictiveLegend(
+ address certAddress,
+ RestrictiveLegend memory newLegend
+ ) external {
+ ICyberCertPrinter(certAddress).addDefaultRestrictiveLegend(newLegend);
+ }
+
+ function executeRemoveDefaultRestrictiveLegendAt(
+ address certAddress,
+ uint256 index
+ ) external {
+ ICyberCertPrinter(certAddress).removeDefaultRestrictiveLegendAt(index);
+ }
+
+ function executeAddCertRestrictiveLegend(
+ address certAddress,
+ uint256 tokenId,
+ RestrictiveLegend memory newLegend
+ ) external {
+ ICyberCertPrinter(certAddress).addCertRestrictiveLegend(tokenId, newLegend);
+ }
+
+ function executeRemoveCertRestrictiveLegendAt(
+ address certAddress,
+ uint256 tokenId,
+ uint256 index
+ ) external {
+ ICyberCertPrinter(certAddress).removeCertRestrictiveLegendAt(tokenId, index);
+ }
+
function executeDeployCyberScrip(
address certAddress,
address auth,
@@ -896,7 +1093,9 @@ library IssuanceManagerStorage {
CertificateDetails memory details = certificate
.getActiveCertificateDetails(id);
- if (amount > details.unitsRepresented) {
+ // Reserved units are committed to pending deals; only free units may be scripified,
+ // otherwise scripify could pull collateral out from under a live reservation.
+ if (amount > details.unitsRepresented - certificate.unitsReserved(id)) {
revert AmountExceedsAvailableUnits();
}
@@ -969,7 +1168,7 @@ library IssuanceManagerStorage {
}
ICyberCertPrinter certificate = ICyberCertPrinter(certAddress);
- RecertSelection memory selection = _selectRecertToken(
+ RecertSelection memory selection = _selectFirstLegalOwnedToken(
certAddress,
account
);
@@ -1187,16 +1386,18 @@ library IssuanceManagerStorage {
emit RecertificationApprovalCleared(certAddress, investor);
}
- function _selectRecertToken(
+ /// @dev First active (non-voided) cert that `owner` is the legal owner of record for, via the printer's
+ /// per-legal-owner enumeration. Independent of ERC-721 custody, so it works under administered hosting
+ /// where a multisig custodies many holders' certs — no scan of the custodian's whole balance.
+ function _selectFirstLegalOwnedToken(
address certAddress,
- address account
+ address owner
) internal view returns (RecertSelection memory selection) {
ICyberCertPrinter certificate = ICyberCertPrinter(certAddress);
- uint256 ownedBalance = certificate.balanceOf(account);
+ uint256 ownedBalance = certificate.balanceOfLegalOwner(owner);
for (uint256 i = 0; i < ownedBalance; i++) {
- uint256 tokenId = certificate.tokenOfOwnerByIndex(account, i);
- if (certificate.legalOwnerOf(tokenId) != account) continue;
+ uint256 tokenId = certificate.tokenOfLegalOwnerByIndex(owner, i);
if (certificate.isVoided(tokenId)) continue;
selection.foundActive = true;
selection.activeTokenId = tokenId;
@@ -1204,11 +1405,15 @@ library IssuanceManagerStorage {
}
}
+ /// @dev Mint a new cert: the NFT is custodied by `to` while `owner` is recorded as the legal owner of
+ /// record. Direct issuance passes to == owner; administered hosting custodies with a multisig (`to`) for
+ /// the buyer/holder of record (`owner`).
function _mintAssignedCert(
address certAddress,
- address investor,
+ address to,
+ address owner,
CertificateDetails memory details,
- string memory investorName
+ string memory ownerName
)
internal
returns (ICyberCertPrinter cert, uint256 tokenId)
@@ -1216,7 +1421,7 @@ library IssuanceManagerStorage {
_requireCompanyDetailsSet();
cert = ICyberCertPrinter(certAddress);
tokenId = cert.totalSupply();
- cert.safeMintAndAssign(investor, tokenId, details, investorName);
+ cert.safeMintAndAssign(to, owner, tokenId, details, ownerName);
_emitCertificateCreated(tokenId, certAddress, details);
}
diff --git a/src/storage/LexScrowStorage.sol b/src/storage/LexScrowStorage.sol
index 09e04e2a..8fb3383f 100644
--- a/src/storage/LexScrowStorage.sol
+++ b/src/storage/LexScrowStorage.sol
@@ -41,7 +41,15 @@ except with the express prior written permission of the copyright holder.*/
pragma solidity 0.8.28;
+import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
+import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
+import "../interfaces/ICyberCorp.sol";
+import "../interfaces/ICyberAgreementRegistry.sol";
+import "../interfaces/ICyberCertPrinter.sol";
import "../interfaces/ICondition.sol";
+import {ILexScrowStorage} from "../interfaces/ILexScrowStorage.sol";
enum TokenType {
ERC20,
@@ -74,7 +82,31 @@ struct Escrow {
EscrowStatus status;
}
+/// @notice Escrow subsystem: storage layout plus the shared escrow logic, deployed once and linked
+/// into managers (DealManager / RoundManager) that DELEGATECALL into it (msg.sender / storage /
+/// address(this) preserved). Internal storage helpers are inlined; the shared escrow ops are `public`
+/// so they are linked + delegatecalled (on-chain dedup). Fee resolution is delegated back to the
+/// calling manager via `ILexScrowStorage(address(this))` so each manager keeps its own fee logic.
library LexScrowStorage {
+ using SafeERC20 for IERC20;
+
+ error DealExpired();
+ error EscrowNotPending();
+ error EscrowNotPaid();
+ error CounterPartyNotSet();
+ error DealNotFullySigned();
+ error DealNotFinalized();
+ error DealAlreadyFinalized();
+ error DealNotVoided();
+ error DealNotPaid();
+ error DealVoided();
+ error DealDoesNotExist();
+
+ event DealVoidedAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
+ event DealPaidAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
+ event DealFinalizedAt(bytes32 indexed agreementId, address agreementRegistry, uint256 timestamp);
+ event FeeDistributed(bytes32 indexed agreementId, address indexed feeToken, uint256 totalFe);
+
// Storage slot for our struct
bytes32 constant STORAGE_POSITION = keccak256("cybercorp.lexscrow.storage.v1");
@@ -107,6 +139,10 @@ library LexScrowStorage {
return lexScrowStorage().escrows[agreementId];
}
+ function hasPrimaryEscrow(bytes32 agreementId) internal view returns (bool) {
+ return lexScrowStorage().escrows[agreementId].agreementId != bytes32(0);
+ }
+
function getConditionsByEscrow(bytes32 agreementId) internal view returns (ICondition[] storage) {
return lexScrowStorage().conditionsByEscrow[agreementId];
}
@@ -137,4 +173,210 @@ library LexScrowStorage {
}
conditions.pop();
}
-}
\ No newline at end of file
+
+ /// @notice Create a new escrow record for an agreement
+ /// @param agreementId Unique identifier of the agreement
+ /// @param counterParty Counterparty/buyer address
+ /// @param corpAssets Assets the company will deliver upon finalization
+ /// @param buyerAssets Assets the counterparty will deliver into escrow
+ /// @param expiry Unix timestamp after which the deal is considered expired
+ function createEscrow(bytes32 agreementId, address counterParty, Token[] memory corpAssets, Token[] memory buyerAssets, uint256 expiry) public {
+ bytes memory blankSignature = abi.encodePacked(bytes32(0));
+ Escrow memory newEscrow = Escrow({
+ agreementId: agreementId,
+ counterParty: counterParty,
+ corpAssets: corpAssets,
+ buyerAssets: buyerAssets,
+ signature: blankSignature,
+ expiry: expiry,
+ status: EscrowStatus.PENDING
+ });
+ setEscrow(agreementId, newEscrow);
+ }
+
+ /// @notice Update escrow counterparty and add endorsement to corp ERC721 certificates
+ /// @param agreementId Unique identifier of the agreement
+ /// @param counterParty Counterparty/buyer address to set
+ /// @param buyerName Human-readable buyer name stored in endorsements
+ function updateEscrow(bytes32 agreementId, address counterParty, string memory buyerName) public {
+ Escrow storage escrow = getEscrow(agreementId);
+ escrow.counterParty = counterParty;
+
+ Endorsement memory newEndorsement = Endorsement(
+ address(this),
+ block.timestamp,
+ escrow.signature,
+ getDealRegistry(),
+ agreementId,
+ escrow.counterParty,
+ buyerName
+ );
+ for(uint256 i = 0; i < escrow.corpAssets.length; i++) {
+ if(escrow.corpAssets[i].tokenType == TokenType.ERC721) {
+ ICyberCertPrinter(escrow.corpAssets[i].tokenAddress).addEndorsement(escrow.corpAssets[i].tokenId, newEndorsement);
+ // check if there is an escrowed officer signature in cybercorp
+ bytes memory officerSignature = "";
+ address corp = getCorp();
+ try ICyberCorp(corp).getEscrowedOfficerSignatureCount() returns (
+ uint256 count
+ ) {
+ if (count > 0) {
+ try ICyberCorp(corp).getEscrowedOfficerSignature(0) returns (bytes memory sig) {
+ officerSignature = sig;
+ } catch {}
+ }
+ } catch {}
+ if (officerSignature.length > 0) {
+ ICyberCertPrinter(escrow.corpAssets[i].tokenAddress).addIssuerSignature(
+ escrow.corpAssets[i].tokenId,
+ officerSignature
+ );
+ }
+ }
+ }
+ }
+
+ /// @notice Pull buyer assets into escrow and mark the escrow as PAID
+ /// @param agreementId Unique identifier of the agreement
+ function handleCounterPartyPayment(bytes32 agreementId) public {
+ Escrow storage escrow = getEscrow(agreementId);
+ if(escrow.status != EscrowStatus.PENDING) revert EscrowNotPending();
+ if(escrow.counterParty == address(0)) revert CounterPartyNotSet();
+
+ for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
+ if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
+ IERC20(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].amount);
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
+ IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].tokenId);
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
+ IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(escrow.counterParty, address(this), escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
+ }
+ }
+
+ emit DealPaidAt(agreementId, getDealRegistry(), block.timestamp);
+ escrow.status = EscrowStatus.PAID;
+ }
+
+ /// @notice Void a PAID escrow and refund all buyer assets
+ /// @dev External callers should implement reentrancy guards
+ /// @param agreementId Unique identifier of the agreement
+ function voidAndRefund(bytes32 agreementId) public {
+ // Check: check status
+ Escrow storage escrow = getEscrow(agreementId);
+ if(escrow.status != EscrowStatus.PAID) revert EscrowNotPaid();
+ if(!ICyberAgreementRegistry(getDealRegistry()).isVoided(agreementId)) revert DealNotVoided();
+
+ // Effect: update status
+ voidEscrow(agreementId);
+
+ // Interaction: Refund buyer assets
+ for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
+ if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
+ IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(escrow.counterParty, escrow.buyerAssets[i].amount);
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
+ IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.buyerAssets[i].tokenId);
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
+ IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
+ }
+ }
+ }
+
+ /// @notice Finalize a PAID escrow, transferring assets and distributing any fees
+ /// @dev External callers should implement reentrancy guards
+ /// @param agreementId Unique identifier of the agreement
+ function finalizeEscrow(bytes32 agreementId) public {
+ Escrow storage escrow = getEscrow(agreementId);
+
+ // Check: Check all conditions before proceeding
+ if(block.timestamp > escrow.expiry) revert DealExpired();
+ if(escrow.status != EscrowStatus.PAID) revert EscrowNotPaid();
+
+ // Effect: Update state before external calls
+ escrow.status = EscrowStatus.FINALIZED;
+ emit DealFinalizedAt(agreementId, getDealRegistry(), block.timestamp);
+
+ // Interaction: Transfer buyer assets to company and collect fees
+ for(uint256 i = 0; i < escrow.buyerAssets.length; i++) {
+ if(escrow.buyerAssets[i].tokenType == TokenType.ERC20) {
+ uint256 amountToCompany = escrow.buyerAssets[i].amount;
+ uint256 fee = 0;
+
+ // Check: if the asset is fee token
+ if (escrow.buyerAssets[i].isFee) {
+ // Effect: Calculate fees. Fee logic lives on the calling manager (DealManager /
+ // RoundManager) — call it back via ILexScrowStorage(address(this)) under delegatecall.
+ fee = ILexScrowStorage(address(this)).computeFee(escrow.buyerAssets[i].amount);
+ amountToCompany -= fee;
+
+ emit FeeDistributed(agreementId, escrow.buyerAssets[i].tokenAddress, fee);
+ }
+
+ // Interaction: Distribute payment and fees
+ if (amountToCompany > 0) {
+ IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(ICyberCorp(getCorp()).companyPayable(), amountToCompany);
+ }
+ if (fee > 0) {
+ IERC20(escrow.buyerAssets[i].tokenAddress).safeTransfer(ILexScrowStorage(address(this)).getPlatformPayable(), fee);
+ }
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC721) {
+ IERC721(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), ICyberCorp(getCorp()).companyPayable(), escrow.buyerAssets[i].tokenId);
+ }
+ else if(escrow.buyerAssets[i].tokenType == TokenType.ERC1155) {
+ IERC1155(escrow.buyerAssets[i].tokenAddress).safeTransferFrom(address(this), ICyberCorp(getCorp()).companyPayable(), escrow.buyerAssets[i].tokenId, escrow.buyerAssets[i].amount, "");
+ }
+ }
+
+ // Interaction: Transfer corp assets to counter party
+ for(uint256 i = 0; i < escrow.corpAssets.length; i++) {
+ if(escrow.corpAssets[i].tokenType == TokenType.ERC20) {
+ IERC20(escrow.corpAssets[i].tokenAddress).safeTransfer(escrow.counterParty, escrow.corpAssets[i].amount);
+ }
+ else if(escrow.corpAssets[i].tokenType == TokenType.ERC721) {
+ IERC721(escrow.corpAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.corpAssets[i].tokenId);
+ }
+ else if(escrow.corpAssets[i].tokenType == TokenType.ERC1155) {
+ IERC1155(escrow.corpAssets[i].tokenAddress).safeTransferFrom(address(this), escrow.counterParty, escrow.corpAssets[i].tokenId, escrow.corpAssets[i].amount, "");
+ }
+ }
+ }
+
+ /// @notice Check all conditions attached to the escrow for the given agreement
+ /// @param agreementId Unique identifier of the agreement
+ /// @return True if all conditions pass, false otherwise
+ function conditionCheck(bytes32 agreementId) public view returns (bool) {
+ ICondition[] storage conditions = getConditionsByEscrow(agreementId);
+ //convert bytes32 to bytes
+ bytes memory agreementIdBytes = abi.encodePacked(agreementId);
+
+ for(uint256 i = 0; i < conditions.length; i++) {
+ if(!ICondition(conditions[i]).checkCondition(address(this), msg.sig, agreementIdBytes))
+ return false;
+ }
+ return true;
+ }
+
+ /// @notice Mark an escrow as VOIDED and emit an event
+ /// @param agreementId Unique identifier of the agreement
+ function voidEscrow(bytes32 agreementId) public {
+ Escrow storage escrow = getEscrow(agreementId);
+ escrow.status = EscrowStatus.VOIDED;
+ emit DealVoidedAt(agreementId, getDealRegistry(), block.timestamp);
+ }
+
+ /// @notice Get escrow details for a given agreement id
+ /// @param agreementId Unique identifier of the agreement
+ /// @return Escrow struct containing current state
+ function getEscrowDetails(bytes32 agreementId) public view returns (Escrow memory) {
+ return getEscrow(agreementId);
+ }
+
+ // NOTE: fee resolution (computeFee / getPlatformPayable) is not declared here. As a library it
+ // cannot have overridable virtuals, so finalizeEscrow calls back into the manager via
+ // ILexScrowStorage(address(this)). The ERC721/ERC1155 receiver hooks live on the managers, since a
+ // library cannot expose externally-callable contract functions.
+}
\ No newline at end of file
diff --git a/src/storage/RoundManagerStorage.sol b/src/storage/RoundManagerStorage.sol
index 64d917f7..afcae0ce 100644
--- a/src/storage/RoundManagerStorage.sol
+++ b/src/storage/RoundManagerStorage.sol
@@ -250,17 +250,9 @@ library RoundManagerStorage {
true // Will be used as fee token
);
- // Emulates LexScroWLite.createEscrow() as we couldn't call it in a library
+ // Create the escrow via the shared LexScrowStorage library (no longer duplicated here)
uint256 expiryForEscrow = round.allowTimedOffers ? eoi.expiry : round.endTime;
- ls.escrows[agreementId] = Escrow({
- agreementId: agreementId,
- counterParty: counterParty,
- corpAssets: corpAssets,
- buyerAssets: buyerAssets,
- signature: abi.encodePacked(bytes32(0)),
- expiry: expiryForEscrow,
- status: EscrowStatus.PENDING
- });
+ LexScrowStorage.createEscrow(agreementId, counterParty, corpAssets, buyerAssets, expiryForEscrow);
if (round.roundType == RoundType.FCFS) {
ICyberAgreementRegistry(ls.DEAL_REGISTRY)
@@ -284,23 +276,8 @@ library RoundManagerStorage {
""
);
- // Emulates LexScroWLite.updateEscrow() as we couldn't call it in a library
- Escrow storage escrow = ls.escrows[agreementId];
- escrow.counterParty = counterParty;
- Endorsement memory newEndorsement = Endorsement(
- address(this),
- block.timestamp,
- escrow.signature,
- ls.DEAL_REGISTRY,
- agreementId,
- escrow.counterParty,
- eoi.name
- );
- for(uint256 i = 0; i < escrow.corpAssets.length; i++) {
- if(escrow.corpAssets[i].tokenType == TokenType.ERC721) {
- ICyberCertPrinter(escrow.corpAssets[i].tokenAddress).addEndorsement(escrow.corpAssets[i].tokenId, newEndorsement);
- }
- }
+ // Update the escrow (set counterparty + endorsements) via the shared LexScrowStorage library
+ LexScrowStorage.updateEscrow(agreementId, counterParty, eoi.name);
setAgreementToRound(agreementId, roundId);
getRoundToAgreements(roundId).push(agreementId);
diff --git a/src/storage/SecondaryTradeStorage.sol b/src/storage/SecondaryTradeStorage.sol
new file mode 100644
index 00000000..fc71bf05
--- /dev/null
+++ b/src/storage/SecondaryTradeStorage.sol
@@ -0,0 +1,889 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "openzeppelin-contracts/token/ERC20/IERC20.sol";
+import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
+import {ECDSA} from "openzeppelin-contracts/utils/cryptography/ECDSA.sol";
+import "../interfaces/ICyberAgreementRegistry.sol";
+import "../interfaces/IIssuanceManager.sol";
+import {ICyberCertPrinter} from "../interfaces/ICyberCertPrinter.sol";
+import "../interfaces/IDealManagerFactory.sol";
+import "../interfaces/IDealManager.sol";
+import "openzeppelin-contracts/utils/introspection/ERC165Checker.sol";
+import "./DealManagerStorage.sol";
+import "./DealManagerFactoryStorage.sol";
+import {LexScrowStorage} from "./LexScrowStorage.sol";
+import {ISecondaryTradingCondition} from "../libs/conditions/BaseSecondaryTradingCondition.sol";
+import {ISecondaryTradeStorage, OfferSide, OfferStatus, SecondaryEscrowStatus, ExemptionPathway, HostingMode, Offer, SecondaryEscrow, PostOfferParams, AcceptOfferParams} from "../interfaces/ISecondaryTradeStorage.sol";
+
+/// @title SecondaryTradeStorage
+/// @notice Diamond storage + secondary-trade business logic for DealManager.
+/// @dev The logic functions are `public`/`external` so the library is deployed separately and linked;
+/// DealManager calls them via DELEGATECALL (msg.sender / storage context preserved), keeping that logic out
+/// of DealManager's bytecode (EIP-170). This path's events/errors live in ISecondaryTradeStorage and are
+/// referenced as ISecondaryTradeStorage.X
+library SecondaryTradeStorage {
+ using SafeERC20 for IERC20;
+ using ECDSA for bytes32;
+
+ bytes32 constant STORAGE_POSITION = keccak256("cybercorp.secondary.trade.storage.v1");
+
+ /// @dev Fallback settlement window used when settlementWindow is unset (0), so an accepted lot always
+ /// gets a finalize window measured from acceptance rather than inheriting the (possibly imminent) offer
+ /// expiry. Must exceed any configured TimeSettlementPeriodCondition minimum delay for the lot to be
+ /// finalizeable; owners with a longer minimum (e.g. QMS-mode) set a larger window via setSettlementWindow.
+ uint256 constant DEFAULT_SETTLEMENT_WINDOW = 7 days;
+
+ // ── EIP-712 relayer-authorization constants (see the relayer overloads of post/cancel/acceptOffer) ──
+ // The signed message binds the full structured params (so a wallet renders each named field), the
+ // principal `forAddr`, and an unordered `nonce` that makes each authorization single-use independent
+ // of any downstream state.
+ bytes32 constant EIP712_DOMAIN_TYPEHASH =
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
+ // Nested-struct EIP-712: the AUTH typehash's encodeType appends the referenced params type (adjacent
+ // string literals concatenate at compile time); the PARAMS typehash (the struct's own type) hashStructs
+ // the params member. The duplicated params-type literals below must stay identical.
+ bytes32 constant POST_OFFER_PARAMS_TYPEHASH = keccak256(
+ "PostOfferParams(uint8 side,address certPrinter,uint256 tokenId,uint256 units,address paymentToken,uint256 consideration,uint8 exemptionPathway,uint256 validUntil,bytes counterpartyRestrictions,bytes additionalTerms,address integrator,bytes32 templateId,uint256 salt,string[] globalValues,string[] offerorPartyValues,bytes offerorAgreementSig,bytes openEndorsementSig,string buyerName,uint8 buyerHostingMode,address adminMultisig)"
+ );
+ bytes32 constant ACCEPT_OFFER_PARAMS_TYPEHASH = keccak256(
+ "AcceptOfferParams(bytes32 offerId,uint256 units,string buyerName,uint8 buyerHostingMode,address adminMultisig,uint256 sellerTokenId,string[] acceptorPartyValues,bytes acceptorAgreementSig,bytes openEndorsementSig)"
+ );
+ bytes32 constant POST_OFFER_AUTH_TYPEHASH = keccak256(
+ "PostOfferAuth(PostOfferParams params,address forAddr,uint256 nonce)"
+ "PostOfferParams(uint8 side,address certPrinter,uint256 tokenId,uint256 units,address paymentToken,uint256 consideration,uint8 exemptionPathway,uint256 validUntil,bytes counterpartyRestrictions,bytes additionalTerms,address integrator,bytes32 templateId,uint256 salt,string[] globalValues,string[] offerorPartyValues,bytes offerorAgreementSig,bytes openEndorsementSig,string buyerName,uint8 buyerHostingMode,address adminMultisig)"
+ );
+ bytes32 constant ACCEPT_OFFER_AUTH_TYPEHASH = keccak256(
+ "AcceptOfferAuth(AcceptOfferParams params,address forAddr,uint256 nonce)"
+ "AcceptOfferParams(bytes32 offerId,uint256 units,string buyerName,uint8 buyerHostingMode,address adminMultisig,uint256 sellerTokenId,string[] acceptorPartyValues,bytes acceptorAgreementSig,bytes openEndorsementSig)"
+ );
+ bytes32 constant CANCEL_OFFER_AUTH_TYPEHASH =
+ keccak256("CancelOfferAuth(bytes32 offerId,address forAddr,uint256 nonce)");
+ bytes32 constant VOID_SECONDARY_AUTH_TYPEHASH =
+ keccak256("VoidSecondaryTradeAuth(bytes32 agreementId,address signer,bytes32 signatureHash,uint256 nonce)");
+
+ // This library's events/errors are declared once in ISecondaryTradeStorage and referenced as
+ // ISecondaryTradeStorage.X below.
+
+ struct SecondaryTradeData {
+ mapping(bytes32 => Offer) offers; // keyed by offerAgreementId
+ mapping(bytes32 => SecondaryEscrow) escrows; // keyed by settlementAgreementId
+ uint256 minTradeUnits;
+ uint256 minTradeConsideration; // 0 = disabled
+ address defaultIntegrator;
+ // Condition config (owner-managed, per-DealManager). Threshold conditions gate post/accept and are
+ // re-checked at finalize; closing conditions gate finalize. Resolved/snapshotted onto each Offer at postOffer so an offer
+ // is governed by the rules in effect when it was posted. Offerors never supply condition addresses.
+ address[] spvThresholdConditions; // Layer 2 — fund-specific (§6); added at SPV onboarding; applies to every offer
+ mapping(ExemptionPathway => address[]) pathwayThresholdConditions; // Layer 1 — exemption-specific (§5); selected by offer.exemptionPathway
+ address[] closingConditions; // default closing set
+ // Consumed relayer-authorization nonces: usedAuthNonce[forAddr][nonce], order-independent single-use.
+ mapping(address => mapping(uint256 => bool)) usedAuthNonce;
+ // Per-DealManager settlement window: how long after acceptance a lot has to finalize before it can be
+ // voided as expired. Decouples settlement expiry from offer expiry (0 = DEFAULT_SETTLEMENT_WINDOW).
+ uint256 settlementWindow;
+ }
+
+ /// @notice Effective settlement window, applying the default when unset (so legacy DealManager does not need migration)
+ function getSettlementWindow() internal view returns (uint256) {
+ uint256 window = secondaryTradeStorage().settlementWindow;
+ return window == 0 ? DEFAULT_SETTLEMENT_WINDOW : window;
+ }
+
+ function secondaryTradeStorage() internal pure returns (SecondaryTradeData storage ds) {
+ bytes32 position = STORAGE_POSITION;
+ assembly {
+ ds.slot := position
+ }
+ }
+
+ function hasSecondaryEscrow(bytes32 agreementId) internal view returns (bool) {
+ return secondaryTradeStorage().escrows[agreementId].counterparty != address(0);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Secondary trade — offer lifecycle (linked logic; called via delegatecall)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice Relayer variant of postOffer: a relayer submits on behalf of `forAddr`, who authorizes the
+ /// call with an EIP-712 signature over `params` + `forAddr` + `nonce`. Offer identity, custody and
+ /// reservations are all attributed to `forAddr`.
+ function postOffer(PostOfferParams calldata params, address forAddr, uint256 nonce, bytes memory sig) external returns (bytes32 offerId) {
+ bytes32 structHash = keccak256(abi.encode(POST_OFFER_AUTH_TYPEHASH, _hashPostOfferParams(params), forAddr, nonce));
+ _verifyForAuth(structHash, forAddr, nonce, sig);
+ return _postOffer(params, forAddr);
+ }
+
+ function postOffer(PostOfferParams calldata params) external returns (bytes32 offerId) {
+ return _postOffer(params, msg.sender);
+ }
+
+ function _postOffer(PostOfferParams calldata params, address offeror) internal returns (bytes32 offerId) {
+ SecondaryTradeData storage ds = secondaryTradeStorage();
+
+ // validate parameters
+ if (params.certPrinter == address(0)) revert ISecondaryTradeStorage.MissingCertPrinter();
+ if (!DealManagerStorage.getIssuanceManager().isPrinter(params.certPrinter))
+ revert ISecondaryTradeStorage.UnknownCertPrinter();
+
+ // Validate integrator
+ address integrator = params.integrator != address(0)
+ ? params.integrator
+ : ds.defaultIntegrator;
+ if (integrator != address(0)) {
+ if (!IDealManagerFactory(DealManagerStorage.getUpgradeFactory()).isIntegratorWhitelisted(integrator))
+ revert ISecondaryTradeStorage.IntegratorNotWhitelisted();
+ }
+
+ // Reject zero-unit offers outright: the min-threshold check below only catches this when a
+ // floor is configured, so a disabled-threshold offer would otherwise mint an empty, un-acceptable offer.
+ if (params.units == 0) revert ISecondaryTradeStorage.BelowMinTradeThreshold();
+
+ // Validate min threshold against the whole offer
+ _checkMinTradeThreshold(params.units, params.consideration);
+
+ // Generate offer ID deterministically — DealManager-internal key, not a registry record
+ offerId = keccak256(abi.encode(offeror, params.templateId, params.salt));
+ if (ds.offers[offerId].offeror != address(0)) revert ISecondaryTradeStorage.OfferAlreadyExists();
+
+ // Resolve conditions from this DealManager's config (never from the caller): threshold = fund-specific
+ // (§6) ++ exemption-specific (§5), closing = the default set. Both are snapshotted onto the offer so it is governed by the rules in
+ // effect at posting; the kill switch still bites later since GlobalKill reads live state internally.
+ address[] memory resolvedThreshold = _resolveThresholdConditions(ds, params.exemptionPathway);
+ address[] memory resolvedClosing = ds.closingConditions;
+
+ // Store offer record before evaluating conditions so seller-side conditions that call
+ // getOffer(offerId) see the populated record; a condition revert rolls this write back.
+ ds.offers[offerId] = Offer({
+ spvAddress: LexScrowStorage.getCorp(),
+ offeror: offeror,
+ side: params.side,
+ certPrinter: params.certPrinter,
+ tokenId: params.tokenId,
+ units: params.units,
+ paymentToken: params.paymentToken,
+ consideration: params.consideration,
+ exemptionPathway: params.exemptionPathway,
+ validUntil: params.validUntil,
+ counterpartyRestrictions: params.counterpartyRestrictions,
+ additionalTerms: params.additionalTerms,
+ integrator: integrator,
+ status: OfferStatus.LIVE,
+ unitsAccepted: 0,
+ paymentAccepted: 0,
+ unitsFinalized: 0,
+ offerId: offerId,
+ templateId: params.templateId,
+ salt: params.salt,
+ globalValues: params.globalValues,
+ offerorPartyValues: params.offerorPartyValues,
+ offerorAgreementSig: params.offerorAgreementSig,
+ openEndorsementSig: params.openEndorsementSig,
+ buyerName: params.side == OfferSide.BUY ? params.buyerName : "",
+ buyerHostingMode: params.side == OfferSide.BUY ? params.buyerHostingMode : HostingMode.DIRECT,
+ adminMultisig: params.side == OfferSide.BUY ? params.adminMultisig : address(0),
+ settlementAgreementIds: new bytes32[](0),
+ // Persisted so they can be re-evaluated at acceptOffer (spec §conditions: threshold
+ // conditions gate both posting and acceptance).
+ thresholdConditions: resolvedThreshold,
+ // Persisted so they can be evaluated at finalize (spec §conditions: closing conditions
+ // gate asset transfer).
+ closingConditions: resolvedClosing
+ });
+
+ // Evaluate threshold conditions (offer is now readable via getOffer). At posting there are no
+ // settlements yet (agreementId == 0), so buyer-facing conditions short-circuit; seller/offer-wide
+ // conditions enforce.
+ _checkThresholdConditions(offerId, bytes32(0));
+
+ if (params.side == OfferSide.SELL) {
+ if (ICyberCertPrinter(params.certPrinter).legalOwnerOf(params.tokenId) != offeror)
+ revert ISecondaryTradeStorage.NotCertOwner();
+ // Reserve units on the seller's cert (routed through IssuanceManager, the only caller the printer allows)
+ DealManagerStorage.getIssuanceManager().increaseUnitsReserved(params.certPrinter, params.tokenId, params.units);
+ } else {
+ // BID: pull consideration directly into contract custody
+ IERC20(params.paymentToken).safeTransferFrom(offeror, address(this), params.consideration);
+ }
+
+ // Emit the full offer record (read back from storage so buy-side fields carry their normalized values)
+ // so an off-chain indexer can rebuild the order book from this single log.
+ Offer storage posted = ds.offers[offerId];
+ emit ISecondaryTradeStorage.OfferPosted(
+ offerId, offeror, params.certPrinter, posted.spvAddress, params.side,
+ params.tokenId, params.units, params.paymentToken, params.consideration,
+ params.exemptionPathway, params.validUntil, integrator,
+ posted.templateId, posted.buyerName, posted.buyerHostingMode, posted.adminMultisig,
+ posted.counterpartyRestrictions, posted.thresholdConditions, posted.closingConditions
+ );
+ }
+
+ /// @notice Relayer variant of cancelOffer: a relayer cancels on behalf of `forAddr`, who authorizes the
+ /// call with an EIP-712 signature over `offerId` + `forAddr` + `nonce`.
+ function cancelOffer(bytes32 offerId, address forAddr, uint256 nonce, bytes memory sig) external {
+ bytes32 structHash = keccak256(abi.encode(CANCEL_OFFER_AUTH_TYPEHASH, offerId, forAddr, nonce));
+ _verifyForAuth(structHash, forAddr, nonce, sig);
+ _cancelOffer(offerId, forAddr);
+ }
+
+ /// @notice Cancels a non-terminal offer and returns its uncommitted assets to the offeror
+ /// @dev Only the free pool (uncommitted units / consideration) is refunded/released. Settlements already
+ /// accepted stay ACCEPTED and resolve on their own — finalized normally, or voided via the two-party
+ /// voidSecondaryTradeAgreement / expiry path; their assets stay in DealManager custody until then.
+ /// @param offerId Offer to cancel
+ function cancelOffer(bytes32 offerId) public {
+ _cancelOffer(offerId, msg.sender);
+ }
+
+ function _cancelOffer(bytes32 offerId, address canceller) internal {
+ Offer storage offer = secondaryTradeStorage().offers[offerId];
+ if (offer.offeror != canceller) revert ISecondaryTradeStorage.NotOfferor();
+ if (_isOfferTerminal(offer.status)) revert ISecondaryTradeStorage.OfferNotAvailable();
+
+ offer.status = OfferStatus.CANCELLED;
+
+ // Return only the free pool; committed lots stay reserved / in custody and are consumed at
+ // finalize or released/refunded when their settlement is voided.
+ if (offer.side == OfferSide.SELL) {
+ // Release only the uncommitted units; in-flight settlement lots are consumed at finalize or released at void
+ uint256 freeUnits = offer.units - offer.unitsAccepted;
+ if (freeUnits > 0) {
+ DealManagerStorage.getIssuanceManager().decreaseUnitsReserved(offer.certPrinter, offer.tokenId, freeUnits);
+ }
+ } else {
+ // BUY: refund only the uncommitted portion; paymentAccepted tracks what's committed to
+ // active and finalized settlements (mirrors unitsAccepted: decrements on void only)
+ uint256 freePayment = offer.consideration - offer.paymentAccepted;
+ if (freePayment > 0) {
+ IERC20(offer.paymentToken).safeTransfer(offer.offeror, freePayment);
+ }
+ }
+
+ emit ISecondaryTradeStorage.OfferCancelled(offerId, canceller);
+ }
+
+ /// @notice Relayer variant of acceptOffer: a relayer accepts on behalf of `forAddr`, who authorizes the
+ /// call with an EIP-712 signature over `params` + `forAddr` + `nonce`. The acceptor identity, custody
+ /// and reservations are all attributed to `forAddr` (whose params.acceptorAgreementSig the registry
+ /// still verifies).
+ function acceptOffer(AcceptOfferParams calldata params, address forAddr, uint256 nonce, bytes memory sig) external returns (bytes32 settlementAgreementId) {
+ bytes32 structHash = keccak256(abi.encode(ACCEPT_OFFER_AUTH_TYPEHASH, _hashAcceptOfferParams(params), forAddr, nonce));
+ _verifyForAuth(structHash, forAddr, nonce, sig);
+ return _acceptOffer(params, forAddr);
+ }
+
+ function acceptOffer(AcceptOfferParams calldata params) public returns (bytes32 settlementAgreementId) {
+ return _acceptOffer(params, msg.sender);
+ }
+
+ function _acceptOffer(AcceptOfferParams calldata params, address acceptor) internal returns (bytes32 settlementAgreementId) {
+ Offer storage offer = secondaryTradeStorage().offers[params.offerId];
+
+ if (offer.status != OfferStatus.LIVE && offer.status != OfferStatus.PARTIALLY_ACCEPTED) revert ISecondaryTradeStorage.OfferNotAvailable();
+ if (block.timestamp > offer.validUntil) revert ISecondaryTradeStorage.OfferExpired();
+
+ // Reject zero-unit fills outright: the min-threshold check below only catches this when a
+ // floor is configured, so a disabled-threshold offer would otherwise mint an empty settlement.
+ if (params.units == 0) revert ISecondaryTradeStorage.BelowMinTradeThreshold();
+ uint256 remainingUnits = offer.units - offer.unitsAccepted;
+ if (params.units > remainingUnits) revert ISecondaryTradeStorage.UnitsExceedOffer();
+
+ // Pro-rata consideration for this (possibly partial) fill. The final lot that exhausts the
+ // remaining units takes the leftover consideration (offer.consideration - offer.paymentAccepted)
+ // rather than another floored pro-rata amount; otherwise flooring across partial fills strands
+ // the rounding remainder — unpaid to the seller, or stuck in custody for a buy offer.
+ uint256 partialConsideration = params.units == remainingUnits
+ ? offer.consideration - offer.paymentAccepted
+ : offer.consideration * params.units / offer.units;
+
+ // Re-apply the admin-set minimum-ticket floors that postOffer enforced on the whole offer, now
+ // against this lot — otherwise a tiny partial fill can settle below the floor. For a non-exhausting
+ // fill we also require the remainder left on the offer to clear the floor, so a sub-floor tail can
+ // never be created; that makes the eventual exhausting fill provably above the floor (by induction
+ // from postOffer's full-offer check) and needs no exemption.
+ if (params.units < remainingUnits) {
+ _checkMinTradeThreshold(params.units, partialConsideration);
+ uint256 remainderUnits = remainingUnits - params.units;
+ uint256 remainderConsideration = (offer.consideration - offer.paymentAccepted) - partialConsideration;
+ _checkMinTradeThreshold(remainderUnits, remainderConsideration);
+ }
+
+ // Settlement window runs from acceptance, not the offer's validUntil: a lot accepted moments before
+ // the offer expires still gets the full window to finalize (and clear any settlement-period minimum
+ // delay), instead of a truncated or already-expired one.
+ uint256 settlementExpiry = block.timestamp + getSettlementWindow();
+
+ // Create fully-signed settlement agreement via registry.
+ // settlementAgreementIds.length is a push-only monotonic nonce: unique per acceptance even if prior
+ // settlements are later voided (which decrements unitsAccepted but never shrinks the array).
+ address registry = LexScrowStorage.getDealRegistry();
+ bytes32 settlementSalt = keccak256(abi.encodePacked(offer.salt, offer.settlementAgreementIds.length));
+ address[] memory settlementParties = new address[](2);
+ settlementParties[0] = offer.offeror;
+ settlementParties[1] = acceptor;
+ string[][] memory settlementPartyValues = new string[][](2);
+ settlementPartyValues[0] = offer.offerorPartyValues;
+ settlementPartyValues[1] = params.acceptorPartyValues;
+ settlementAgreementId = ICyberAgreementRegistry(registry).createContract(
+ offer.templateId,
+ uint256(settlementSalt),
+ offer.globalValues,
+ settlementParties,
+ settlementPartyValues,
+ bytes32(0),
+ address(this),
+ settlementExpiry
+ );
+ // Offeror: DealManager (finalizer) attests commitment via signContractWithEscrow.
+ // The registry does not verify escrowSigner's EIP-712 sig here; the offeror's
+ // commitment is evidenced by their postOffer() tx and stored offerorAgreementSig.
+ ICyberAgreementRegistry(registry).signContractWithEscrow(
+ offer.offeror, settlementAgreementId, offer.offerorPartyValues,
+ offer.offerorAgreementSig, false, ""
+ );
+ // Acceptor: proper EIP-712 sig verified by the registry.
+ ICyberAgreementRegistry(registry).signContractFor(
+ acceptor, settlementAgreementId, params.acceptorPartyValues,
+ params.acceptorAgreementSig, false, ""
+ );
+
+ // Resolve cert printer, tokenId, buyer, and endorsement sig per offer side. The seller's open-endorsement
+ // signature is only captured here (parked on the SecondaryEscrow below); it is not written to the token
+ // until finalization, where secondaryTransfer materializes the real endorsement with the known buyer.
+ address certPrinter;
+ uint256 tokenId;
+ address buyer;
+ bytes memory endorsementSig;
+
+ if (offer.side == OfferSide.SELL) {
+ certPrinter = offer.certPrinter;
+ tokenId = offer.tokenId;
+ buyer = acceptor;
+ endorsementSig = offer.openEndorsementSig;
+ // Re-check the seller still owns the cert
+ if (ICyberCertPrinter(certPrinter).legalOwnerOf(tokenId) != offer.offeror)
+ revert ISecondaryTradeStorage.SecondaryTradeSellerOwnershipChanged();
+ } else {
+ certPrinter = offer.certPrinter;
+ tokenId = params.sellerTokenId;
+ buyer = offer.offeror;
+ endorsementSig = params.openEndorsementSig;
+ if (ICyberCertPrinter(certPrinter).legalOwnerOf(tokenId) != acceptor)
+ revert ISecondaryTradeStorage.NotCertOwner();
+ // Reserve units on the seller's cert at acceptance (bid flow, routed through IssuanceManager)
+ DealManagerStorage.getIssuanceManager().increaseUnitsReserved(certPrinter, tokenId, params.units);
+ }
+
+ // Resolve the buyer info per side: bids carry it on the offer (the offeror is the buyer),
+ // sells take it from the acceptance.
+ string memory buyerName;
+ HostingMode buyerHostingMode;
+ address adminMultisig;
+ if (offer.side == OfferSide.BUY) {
+ buyerName = offer.buyerName;
+ buyerHostingMode = offer.buyerHostingMode;
+ adminMultisig = offer.adminMultisig;
+ } else {
+ buyerName = params.buyerName;
+ buyerHostingMode = params.buyerHostingMode;
+ adminMultisig = params.adminMultisig;
+ }
+
+ // Fund the settlement escrow.
+ // BUY: funds are already in contract from postOffer(); no token movement needed.
+ // SELL: pull the buyer's payment directly into contract.
+ if (offer.side == OfferSide.SELL) {
+ IERC20(offer.paymentToken).safeTransferFrom(buyer, address(this), partialConsideration);
+ }
+ secondaryTradeStorage().escrows[settlementAgreementId] = SecondaryEscrow({
+ counterparty: acceptor,
+ paymentToken: offer.paymentToken,
+ paymentAmount: partialConsideration,
+ units: params.units,
+ expiry: settlementExpiry,
+ status: SecondaryEscrowStatus.ACCEPTED,
+ feeDestination: offer.integrator,
+ offerId: params.offerId,
+ tokenId: tokenId,
+ buyerName: buyerName,
+ buyerHostingMode: buyerHostingMode,
+ adminMultisig: adminMultisig,
+ openEndorsementSig: endorsementSig
+ });
+
+ // Record settlement for buyer-facing threshold condition lookup
+ offer.settlementAgreementIds.push(settlementAgreementId);
+
+ // Re-evaluate threshold conditions now that a settlement exists: buyer-facing conditions
+ // (KYC/AML, accreditation, holder caps, etc.) that short-circuited at posting resolve the
+ // acceptor via the settlementAgreementId passed here and enforce. A failure reverts the whole
+ // acceptance, undoing the settlement, escrow funding, and reservations above.
+ _checkThresholdConditions(params.offerId, settlementAgreementId);
+
+ // Update offer accounting and fill state
+ offer.unitsAccepted += params.units;
+ offer.paymentAccepted += partialConsideration;
+ if (offer.unitsAccepted >= offer.units) {
+ offer.status = OfferStatus.FULLY_ACCEPTED;
+ } else {
+ offer.status = OfferStatus.PARTIALLY_ACCEPTED;
+ }
+
+ // Acceptance funds the escrow atomically, so this event carries the settlement's payment too.
+ emit ISecondaryTradeStorage.OfferAccepted(
+ params.offerId, settlementAgreementId, acceptor, params.units, offer.paymentToken, partialConsideration,
+ tokenId, settlementExpiry,
+ buyerName, buyerHostingMode, adminMultisig, endorsementSig
+ );
+ }
+
+ /// @dev abi-encodes the ownership-change params for IssuanceManager.secondaryTransfer. Built from
+ /// per-settlement state (the offer plus the SecondaryEscrow), so acceptOffer and finalize stay in lockstep.
+ function _encodeDealMetadata(
+ address certPrinter,
+ uint256 tokenId,
+ uint256 units,
+ address buyer,
+ string memory buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig,
+ ExemptionPathway exemptionPathway,
+ bytes32 settlementAgreementId,
+ bytes memory openEndorsementSig
+ ) internal pure returns (bytes memory) {
+ return abi.encode(
+ certPrinter, tokenId, units, buyer, buyerName,
+ buyerHostingMode, adminMultisig, exemptionPathway, settlementAgreementId, openEndorsementSig
+ );
+ }
+
+ /// @notice Finalizes an accepted secondary-trade settlement (pays the seller, executes the ownership change)
+ /// @dev Secondary counterpart of DealManager.finalizeDeal. Self-contained: the local escrow status is the
+ /// source of truth, so _requireUnconcludedSecondaryEscrow rejects already-finalized/voided settlements; the
+ /// settlement is created fully-signed at acceptOffer, so no all-parties-signed check is needed here.
+ function finalizeSecondaryTradeAgreement(bytes32 agreementId) external {
+ SecondaryEscrow storage secEscrow = secondaryTradeStorage().escrows[agreementId];
+ Offer storage offer = secondaryTradeStorage().offers[secEscrow.offerId];
+
+ // Validate the local escrow (source of truth) and fail with local errors BEFORE the external
+ // registry call, so an already-finalized/voided settlement never reaches finalizeContract.
+ _requireUnconcludedSecondaryEscrow(secEscrow);
+ ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).finalizeContract(agreementId);
+
+ // Defensive backstop: finalizeContract already reverts ContractExpired on the same deadline, so this
+ // local guard is effectively unreachable, but kept in case the registry and escrow expiry ever diverge.
+ if (block.timestamp > secEscrow.expiry) revert ISecondaryTradeStorage.SecondaryTradeAgreementExpired();
+ // Re-check threshold (eligibility) conditions at finalization: a buyer who was eligible at
+ // acceptance may have lost eligibility before settlement (credential revoked, holder cap
+ // breached, blocked-state move, kill of an approval). Both ids are known, so buyer-facing
+ // conditions read this lot's acceptor directly.
+ _checkThresholdConditions(secEscrow.offerId, agreementId);
+
+ address[] storage conditions = offer.closingConditions;
+ for (uint256 i = 0; i < conditions.length; i++) {
+ // note: always double check if `Offer` is properly updated because `checkCondition()` depends on it
+ if (!ISecondaryTradingCondition(conditions[i]).checkCondition(IDealManager(address(this)), msg.sig, secEscrow.offerId, agreementId))
+ revert ISecondaryTradeStorage.SecondaryConditionsNotMet(conditions[i]);
+ }
+
+ // Effect: mark finalized before external calls
+ secEscrow.status = SecondaryEscrowStatus.FINALIZED;
+ offer.unitsFinalized += secEscrow.units;
+ // All offered units settled: the offer reaches its FINALIZED terminal state. CANCELLED
+ // stays sticky — both are terminal, and cancellation is the offeror's recorded intent.
+ if (offer.status != OfferStatus.CANCELLED && offer.unitsFinalized == offer.units) {
+ offer.status = OfferStatus.FINALIZED;
+ }
+ (address seller, address buyer) = _settlementParties(offer, secEscrow);
+ // Require seller ownership to remain unchanged; if it was a legitimately-moved
+ // position it'd still revert here and it could be resolved via the void/expiry path instead of mispaying.
+ if (ICyberCertPrinter(offer.certPrinter).legalOwnerOf(secEscrow.tokenId) != seller)
+ revert ISecondaryTradeStorage.SecondaryTradeSellerOwnershipChanged();
+ // Fee math (mirrors DealManager.computeFee / getPlatformPayable) computed directly from the factory
+ address upgradeFactory = DealManagerStorage.getUpgradeFactory();
+ uint256 fee = secEscrow.paymentAmount * IDealManagerFactory(upgradeFactory).getDefaultFeeRatio() / DealManagerFactoryStorage.BASIS_POINTS;
+ uint256 toSeller = secEscrow.paymentAmount - fee;
+
+ if (toSeller > 0) {
+ IERC20(secEscrow.paymentToken).safeTransfer(seller, toSeller);
+ }
+
+ if (fee > 0) {
+ // Re-validate the integrator against the whitelist at settlement: per spec §12B.4 a
+ // de-whitelisted integrator falls through to the unsplit MetaLeX-only flow (never reverts).
+ address feeDestination = secEscrow.feeDestination;
+ uint256 integratorFee;
+ if (feeDestination != address(0) && IDealManagerFactory(upgradeFactory).isIntegratorWhitelisted(feeDestination)) {
+ // Per spec §12B.4: this integrator's own share of the fee, keyed by feeDestination.
+ uint256 integratorRatio = IDealManagerFactory(upgradeFactory).getIntegratorFeeShare(feeDestination);
+ integratorFee = fee * integratorRatio / DealManagerFactoryStorage.BASIS_POINTS;
+ } else {
+ feeDestination = address(0); // fell through to platform-only; report no credited integrator
+ }
+ uint256 platformFee = fee - integratorFee;
+ if (integratorFee > 0) IERC20(secEscrow.paymentToken).safeTransfer(feeDestination, integratorFee);
+ if (platformFee > 0) IERC20(secEscrow.paymentToken).safeTransfer(IDealManagerFactory(upgradeFactory).getPlatformPayable(), platformFee);
+ // Emit the realized split (feeDestination zero = platform-only) so an indexer needn't recompute it.
+ emit ISecondaryTradeStorage.SecondaryFeeDistributed(agreementId, secEscrow.paymentToken, feeDestination, fee, integratorFee, platformFee);
+ }
+
+ // Ready to transfer units, release this lot's reservation first
+ DealManagerStorage.getIssuanceManager().decreaseUnitsReserved(offer.certPrinter, secEscrow.tokenId, secEscrow.units);
+
+ // Execute ownership change: void/decrement seller cert + mint buyer cert.
+ DealManagerStorage.getIssuanceManager().secondaryTransfer(
+ _encodeDealMetadata(
+ offer.certPrinter, secEscrow.tokenId, secEscrow.units, buyer,
+ secEscrow.buyerName, secEscrow.buyerHostingMode, secEscrow.adminMultisig,
+ offer.exemptionPathway, agreementId, secEscrow.openEndorsementSig
+ )
+ );
+
+ emit ISecondaryTradeStorage.SecondaryTradeAgreementFinalized(agreementId, seller, buyer, secEscrow.units, secEscrow.paymentAmount);
+ }
+
+ /// @notice Voids an expired secondary-trade settlement and refunds/releases its escrowed assets
+ /// @dev Secondary counterpart of DealManager.voidExpiredDeal.
+ function voidExpiredSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature) external {
+ SecondaryEscrow storage secEscrow = secondaryTradeStorage().escrows[agreementId];
+ _requireUnconcludedSecondaryEscrow(secEscrow);
+ if (block.timestamp <= secEscrow.expiry) revert ISecondaryTradeStorage.SecondaryTradeAgreementNotExpired();
+ ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).voidContractFor(agreementId, signer, signature);
+ _voidSecondaryTradeAgreement(agreementId);
+ }
+
+ /// @notice Records a party's request to void an ACCEPTED secondary settlement before it is finalized or expires
+ /// @dev Finalizer-vouched request channel: the registry voids the agreement only once BOTH parties have
+ /// requested (or it is past expiry). The local escrow is settled only when that actually happens, keeping
+ /// DealManager and the registry in sync; a lone request just records intent and the counterparty can still finalize.
+ /// @param agreementId Settlement agreement to void
+ /// @param signer Caller's address (must equal msg.sender)
+ /// @param signature Caller's EIP-712 void signature, forwarded to the agreement registry
+ function voidSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature) external {
+ if (msg.sender != signer) revert ISecondaryTradeStorage.NotSigner();
+ _requestVoidSecondaryTradeAgreement(agreementId, signer, signature, msg.sender);
+ }
+
+ /// @notice Relayer variant: a relayer submits `signer`'s void request on their behalf. `signer` authorizes
+ /// with an EIP-712 signature over `agreementId` + `signer` + keccak256(void signature) + `nonce`; the void
+ /// `signature` itself is still verified against `signer` downstream by the registry.
+ function voidSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature, uint256 nonce, bytes memory authSig) external {
+ bytes32 structHash = keccak256(abi.encode(VOID_SECONDARY_AUTH_TYPEHASH, agreementId, signer, keccak256(signature), nonce));
+ _verifyForAuth(structHash, signer, nonce, authSig);
+ _requestVoidSecondaryTradeAgreement(agreementId, signer, signature, signer);
+ }
+
+ function _requestVoidSecondaryTradeAgreement(bytes32 agreementId, address signer, bytes memory signature, address party) internal {
+ SecondaryEscrow storage secEscrow = secondaryTradeStorage().escrows[agreementId];
+ _requireUnconcludedSecondaryEscrow(secEscrow);
+ Offer storage offer = secondaryTradeStorage().offers[secEscrow.offerId];
+ if (party != secEscrow.counterparty && party != offer.offeror) revert ISecondaryTradeStorage.NotPartyToAgreement();
+ address registry = LexScrowStorage.getDealRegistry();
+ ICyberAgreementRegistry(registry).voidContractFor(agreementId, signer, signature);
+ // A lone request only records intent; the registry voids once both parties have requested
+ // (or past expiry). Settle the escrow locally only when that actually happens.
+ if (ICyberAgreementRegistry(registry).isVoided(agreementId)) {
+ _voidSecondaryTradeAgreement(agreementId);
+ }
+ }
+
+ /// @notice Syncs a secondary settlement that was voided directly in the agreement registry
+ /// @dev Callable by anyone; guards against double-void via the terminal-state checks
+ /// @param agreementId Settlement agreement that was already voided in the registry
+ function syncVoidedSecondaryTradeAgreement(bytes32 agreementId) external {
+ SecondaryEscrow storage secEscrow = secondaryTradeStorage().escrows[agreementId];
+ _requireUnconcludedSecondaryEscrow(secEscrow);
+ if (!ICyberAgreementRegistry(LexScrowStorage.getDealRegistry()).isVoided(agreementId)) revert ISecondaryTradeStorage.SecondaryTradeAgreementNotVoided();
+ _voidSecondaryTradeAgreement(agreementId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Secondary trade — internals
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @dev Reverts if `units` or `consideration` falls below the admin-set minimum-ticket floors
+ /// (either floor disabled when 0). Shared by postOffer (whole offer) and acceptOffer (per lot)
+ /// so both gates stay in lockstep.
+ function _checkMinTradeThreshold(uint256 units, uint256 consideration) internal view {
+ SecondaryTradeData storage ds = secondaryTradeStorage();
+ if (ds.minTradeUnits > 0 && units < ds.minTradeUnits) revert ISecondaryTradeStorage.BelowMinTradeThreshold();
+ if (ds.minTradeConsideration > 0 && consideration < ds.minTradeConsideration) revert ISecondaryTradeStorage.BelowMinTradeThreshold();
+ }
+
+ /// @dev Walks the offer's stored threshold conditions, reverting on the first failure. Conditions
+ /// receive the uniform secondary-trade payload `abi.encode(offerId, agreementId)`; `agreementId` is
+ /// `bytes32(0)` at posting (no settlement yet) and the settlement id at acceptance and at finalization,
+ /// so a buyer-facing condition reads its acceptor directly instead of reaching into settlementAgreementIds.
+ /// Re-run at finalization so eligibility lost between acceptance and settlement blocks the asset transfer.
+ /// The selector handed to conditions is `msg.sig` — the gated entrypoint's own selector, so the relayer
+ /// overloads present their own selector (not the direct-call one); see ISecondaryTradingCondition.
+ function _checkThresholdConditions(bytes32 offerId, bytes32 agreementId) internal view {
+ address[] storage conditions = secondaryTradeStorage().offers[offerId].thresholdConditions;
+ for (uint256 i = 0; i < conditions.length; i++) {
+ // note: always double check if `Offer` is properly updated because `checkCondition()` depends on it
+ if (!ISecondaryTradingCondition(conditions[i]).checkCondition(IDealManager(address(this)), msg.sig, offerId, agreementId))
+ revert ISecondaryTradeStorage.SecondaryConditionsNotMet(conditions[i]);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Relayer-authorization internals (EIP-712 + unordered nonce)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @dev EIP-712 domain separator, bound to this DealManager. Under delegatecall `address(this)` is the
+ /// DealManager proxy, so it is the correct verifyingContract; computed per-call (not cached) since the
+ /// linked library holds no immutables.
+ function _domainSeparator() internal view returns (bytes32) {
+ return keccak256(abi.encode(
+ EIP712_DOMAIN_TYPEHASH,
+ keccak256(bytes("DealManager")),
+ keccak256(bytes("1")),
+ block.chainid,
+ address(this)
+ ));
+ }
+
+ /// @dev EIP-712 hashStruct of PostOfferParams (dynamic members pre-hashed, enums as uint8).
+ function _hashPostOfferParams(PostOfferParams calldata p) internal pure returns (bytes32) {
+ return keccak256(abi.encode(
+ POST_OFFER_PARAMS_TYPEHASH,
+ uint8(p.side),
+ p.certPrinter,
+ p.tokenId,
+ p.units,
+ p.paymentToken,
+ p.consideration,
+ uint8(p.exemptionPathway),
+ p.validUntil,
+ keccak256(p.counterpartyRestrictions),
+ keccak256(p.additionalTerms),
+ p.integrator,
+ p.templateId,
+ p.salt,
+ _hashStringArray(p.globalValues),
+ _hashStringArray(p.offerorPartyValues),
+ keccak256(p.offerorAgreementSig),
+ keccak256(p.openEndorsementSig),
+ keccak256(bytes(p.buyerName)),
+ uint8(p.buyerHostingMode),
+ p.adminMultisig
+ ));
+ }
+
+ /// @dev EIP-712 hashStruct of AcceptOfferParams (dynamic members pre-hashed, enums as uint8).
+ function _hashAcceptOfferParams(AcceptOfferParams calldata p) internal pure returns (bytes32) {
+ return keccak256(abi.encode(
+ ACCEPT_OFFER_PARAMS_TYPEHASH,
+ p.offerId,
+ p.units,
+ keccak256(bytes(p.buyerName)),
+ uint8(p.buyerHostingMode),
+ p.adminMultisig,
+ p.sellerTokenId,
+ _hashStringArray(p.acceptorPartyValues),
+ keccak256(p.acceptorAgreementSig),
+ keccak256(p.openEndorsementSig)
+ ));
+ }
+
+ /// @dev EIP-712 array encoding: keccak256 over the concatenated per-element hashes.
+ function _hashStringArray(string[] calldata arr) internal pure returns (bytes32) {
+ bytes32[] memory hashes = new bytes32[](arr.length);
+ for (uint256 i = 0; i < arr.length; i++) {
+ hashes[i] = keccak256(bytes(arr[i]));
+ }
+ return keccak256(abi.encodePacked(hashes));
+ }
+
+ /// @dev Consumes an unordered nonce: reverts if already used, else marks it used. Order-independent,
+ /// single-use per (forAddr, nonce).
+ function _useUnorderedNonce(address from, uint256 nonce) internal {
+ SecondaryTradeData storage ds = secondaryTradeStorage();
+ if (ds.usedAuthNonce[from][nonce]) revert ISecondaryTradeStorage.SecondaryAuthReplayed();
+ ds.usedAuthNonce[from][nonce] = true;
+ }
+
+ /// @dev Verifies a relayer overload's EIP-712 authorization: `sig` must recover to `forAddr` over the
+ /// domain-separated `structHash`, then the nonce is consumed. Consumption only sticks if the whole call
+ /// succeeds (a later revert rolls the write back), so failed calls do not burn the authorization.
+ function _verifyForAuth(bytes32 structHash, address forAddr, uint256 nonce, bytes memory sig) internal {
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash));
+ if (digest.recover(sig) != forAddr) revert ISecondaryTradeStorage.InvalidSecondaryAuthSignature();
+ _useUnorderedNonce(forAddr, nonce);
+ }
+
+ /// @dev Builds an offer's threshold set from this DealManager's config per v3.53 §7.2: the fund-specific
+ /// (§6, per-SPV) layer ++ the exemption-specific (§5) layer registered for the offer's exemption pathway.
+ /// Insertion order is preserved so failures surface deterministically. Offerors choose the pathway, never
+ /// the condition addresses.
+ function _resolveThresholdConditions(SecondaryTradeData storage ds, ExemptionPathway pathway)
+ internal view returns (address[] memory resolved)
+ {
+ address[] storage spv = ds.spvThresholdConditions;
+ address[] storage pathwayConds = ds.pathwayThresholdConditions[pathway];
+
+ resolved = new address[](spv.length + pathwayConds.length);
+ uint256 k;
+ for (uint256 i = 0; i < spv.length; i++) resolved[k++] = spv[i];
+ for (uint256 i = 0; i < pathwayConds.length; i++) resolved[k++] = pathwayConds[i];
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Condition config management (linked logic; called via delegatecall by DealManager owner setters)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function addSpvThresholdCondition(address condition) external {
+ _addCondition(secondaryTradeStorage().spvThresholdConditions, condition);
+ }
+
+ function removeSpvThresholdConditionAt(uint256 index) external {
+ _removeConditionAt(secondaryTradeStorage().spvThresholdConditions, index);
+ }
+
+ function addPathwayThresholdCondition(ExemptionPathway pathway, address condition) external {
+ _addCondition(secondaryTradeStorage().pathwayThresholdConditions[pathway], condition);
+ }
+
+ function removePathwayThresholdConditionAt(ExemptionPathway pathway, uint256 index) external {
+ _removeConditionAt(secondaryTradeStorage().pathwayThresholdConditions[pathway], index);
+ }
+
+ function addClosingCondition(address condition) external {
+ _addCondition(secondaryTradeStorage().closingConditions, condition);
+ }
+
+ function removeClosingConditionAt(uint256 index) external {
+ _removeConditionAt(secondaryTradeStorage().closingConditions, index);
+ }
+
+ /// @dev Appends to an owner-managed condition list, rejecting the zero address and duplicates so the
+ /// list behaves as a set. Lists are small (admin-curated), so the linear dedupe scan is cheap.
+ function _addCondition(address[] storage list, address condition) internal {
+ if (condition == address(0)) revert ISecondaryTradeStorage.InvalidSecondaryCondition();
+ // Only strongly-typed secondary-trading conditions may be wired in: reject anything that doesn't
+ // advertise ISecondaryTradingCondition via ERC-165 at config time, so a mismatched checkCondition
+ // signature can never reach post/accept/finalize. ERC165Checker returns false (no revert) for
+ // non-ERC165 targets.
+ if (!ERC165Checker.supportsInterface(condition, type(ISecondaryTradingCondition).interfaceId))
+ revert ISecondaryTradeStorage.SecondaryConditionInterfaceUnsupported(condition);
+ for (uint256 i = 0; i < list.length; i++) {
+ if (list[i] == condition) revert ISecondaryTradeStorage.SecondaryConditionAlreadyExists();
+ }
+ list.push(condition);
+ }
+
+ /// @dev Swap-pop removal — order within the list is not significant for membership, only for the
+ /// deterministic evaluation order of an already-posted offer (which snapshots its own copy anyway).
+ function _removeConditionAt(address[] storage list, uint256 index) internal {
+ uint256 len = list.length;
+ if (index >= len) revert ISecondaryTradeStorage.SecondaryConditionIndexOutOfBounds();
+ list[index] = list[len - 1];
+ list.pop();
+ }
+
+ /// @dev Terminal offer states: immutable, not cancellable, and never restored by a void
+ function _isOfferTerminal(OfferStatus status) internal pure returns (bool) {
+ return status == OfferStatus.CANCELLED || status == OfferStatus.FINALIZED;
+ }
+
+ /// @dev Derives the settlement's seller and buyer from the offer side: the offeror is the
+ /// seller on SELL offers and the buyer on BUY offers; the counterparty (acceptor) is the other.
+ function _settlementParties(Offer storage offer, SecondaryEscrow storage secEscrow)
+ internal view returns (address seller, address buyer)
+ {
+ return offer.side == OfferSide.SELL
+ ? (offer.offeror, secEscrow.counterparty)
+ : (secEscrow.counterparty, offer.offeror);
+ }
+
+ /// @dev Reverts unless the settlement escrow exists and is not yet concluded (status still ACCEPTED,
+ /// i.e. neither FINALIZED nor VOIDED). Note "unconcluded" is not "unexpired": an escrow past its
+ /// `expiry` is still ACCEPTED and passes here — expiry alone is not a terminal state, the lot just
+ /// awaits finalize or a void. The existence check must come first: SecondaryEscrowStatus.ACCEPTED == 0,
+ /// so an absent escrow would otherwise read as ACCEPTED and pass. This positively validates the secondary
+ /// id space (mirrors the primary side's LexScrowStorage.hasPrimaryEscrow guard). Terminal states get
+ /// explicit errors so callers never act on an already-settled escrow.
+ function _requireUnconcludedSecondaryEscrow(SecondaryEscrow storage secEscrow) internal view {
+ if (secEscrow.counterparty == address(0)) revert ISecondaryTradeStorage.SecondaryEscrowNotFound();
+ if (secEscrow.status == SecondaryEscrowStatus.FINALIZED) revert ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyFinalized();
+ if (secEscrow.status == SecondaryEscrowStatus.VOIDED) revert ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided();
+ }
+
+ function _voidSecondaryTradeAgreement(bytes32 agreementId) internal {
+ SecondaryEscrow storage secEscrow = secondaryTradeStorage().escrows[agreementId];
+ Offer storage offer = secondaryTradeStorage().offers[secEscrow.offerId];
+
+ // Update accounting counters
+ offer.unitsAccepted -= secEscrow.units;
+ offer.paymentAccepted -= secEscrow.paymentAmount;
+
+ // Release this lot's unit reservation.
+ // BUY: reserved at acceptance for this settlement only — always release.
+ // SELL: reserved at postOffer for the whole offer — release only when the offer is CANCELLED
+ // (the lot can never be re-accepted); otherwise the lot returns to the offer's free pool
+ // and stays reserved.
+ if (offer.side == OfferSide.BUY || offer.status == OfferStatus.CANCELLED) {
+ DealManagerStorage.getIssuanceManager().decreaseUnitsReserved(offer.certPrinter, secEscrow.tokenId, secEscrow.units);
+ }
+
+ // Restore offer status (keep terminal offers closed)
+ if (!_isOfferTerminal(offer.status)) {
+ offer.status = offer.unitsAccepted == 0 ? OfferStatus.LIVE : OfferStatus.PARTIALLY_ACCEPTED;
+ }
+
+ bool wasAccepted = secEscrow.status == SecondaryEscrowStatus.ACCEPTED;
+ secEscrow.status = SecondaryEscrowStatus.VOIDED;
+ emit ISecondaryTradeStorage.SecondaryTradeAgreementVoided(agreementId);
+ if (wasAccepted) {
+ // Refund mirrors the reservation logic above, with sides swapped.
+ // SELL: payment was pulled per-settlement at acceptOffer — always refund the buyer.
+ // BUY: payment came from the offer's pool at postOffer — refund only when the offer is
+ // CANCELLED (the lot can never be re-accepted); otherwise the payment returns to the
+ // offer's free pool and stays in custody.
+ if (offer.side == OfferSide.SELL || offer.status == OfferStatus.CANCELLED) {
+ (, address buyer) = _settlementParties(offer, secEscrow);
+ IERC20(secEscrow.paymentToken).safeTransfer(buyer, secEscrow.paymentAmount);
+ }
+ }
+ }
+}
diff --git a/src/storage/extensions/CyberCorpComplianceExtension.sol b/src/storage/extensions/CyberCorpComplianceExtension.sol
new file mode 100644
index 00000000..53ff6b55
--- /dev/null
+++ b/src/storage/extensions/CyberCorpComplianceExtension.sol
@@ -0,0 +1,223 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./ICyberCorpExtension.sol";
+import "../../libs/auth.sol";
+
+struct FeeDetail {
+ string feeName;
+ uint256 feeBps;
+ uint256 flatFee;
+ address recipient;
+ string feeToken;
+ string notes;
+}
+
+struct CyberCorpComplianceData {
+ bool erisaAllowed;
+ uint256 maxOwnershipBps;
+ uint256 minNonZeroOwnershipBps;
+ uint256 maxHolderCount;
+ bool cfiusApprovalRequired;
+ string[] holderRestrictions;
+ FeeDetail[] feeDetails;
+}
+
+contract CyberCorpComplianceExtension is
+ UUPSUpgradeable,
+ ICyberCorpExtension,
+ BorgAuthACL
+{
+ bytes32 public constant EXTENSION_TYPE =
+ keccak256("CYBERCORP_COMPLIANCE");
+
+ uint256[30] private __gap;
+
+ function initialize(address _auth) external initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function decodeExtensionData(
+ bytes memory data
+ ) external pure returns (CyberCorpComplianceData memory) {
+ return abi.decode(data, (CyberCorpComplianceData));
+ }
+
+ function encodeExtensionData(
+ CyberCorpComplianceData memory data
+ ) external pure returns (bytes memory) {
+ return abi.encode(data);
+ }
+
+ function supportsExtensionType(
+ bytes32 extensionType
+ ) external pure override returns (bool) {
+ return extensionType == EXTENSION_TYPE;
+ }
+
+ function getExtensionURI(
+ bytes memory data
+ ) external pure override returns (string memory) {
+ CyberCorpComplianceData memory decoded = abi.decode(
+ data,
+ (CyberCorpComplianceData)
+ );
+
+ return string(
+ abi.encodePacked(
+ ', "CyberCorpCompliance": {',
+ '"erisaAllowed": "',
+ boolToString(decoded.erisaAllowed),
+ '", "maxOwnershipBps": "',
+ uint256ToString(decoded.maxOwnershipBps),
+ '", "minNonZeroOwnershipBps": "',
+ uint256ToString(decoded.minNonZeroOwnershipBps),
+ '", "maxHolderCount": "',
+ uint256ToString(decoded.maxHolderCount),
+ '", "cfiusApprovalRequired": "',
+ boolToString(decoded.cfiusApprovalRequired),
+ '", "holderRestrictions": ',
+ stringArrayToJson(decoded.holderRestrictions),
+ ', "feeDetails": ',
+ feeDetailsToJson(decoded.feeDetails),
+ "}"
+ )
+ );
+ }
+
+ function feeDetailsToJson(
+ FeeDetail[] memory details
+ ) internal pure returns (string memory) {
+ string memory json = "[";
+
+ for (uint256 i = 0; i < details.length; i++) {
+ if (i > 0) {
+ json = string.concat(json, ", ");
+ }
+
+ json = string.concat(
+ json,
+ '{"feeName": "',
+ details[i].feeName,
+ '", "feeBps": "',
+ uint256ToString(details[i].feeBps),
+ '", "flatFee": "',
+ uint256ToString(details[i].flatFee),
+ '", "recipient": "',
+ addressToString(details[i].recipient),
+ '", "feeToken": "',
+ details[i].feeToken,
+ '", "notes": "',
+ details[i].notes,
+ '"}'
+ );
+ }
+
+ return string.concat(json, "]");
+ }
+
+ function stringArrayToJson(
+ string[] memory values
+ ) internal pure returns (string memory) {
+ string memory json = "[";
+
+ for (uint256 i = 0; i < values.length; i++) {
+ if (i > 0) {
+ json = string.concat(json, ", ");
+ }
+ json = string.concat(json, '"', values[i], '"');
+ }
+
+ return string.concat(json, "]");
+ }
+
+ function boolToString(bool value) internal pure returns (string memory) {
+ return value ? "true" : "false";
+ }
+
+ function uint256ToString(uint256 value) internal pure returns (string memory) {
+ if (value == 0) {
+ return "0";
+ }
+
+ uint256 temp = value;
+ uint256 digits;
+ while (temp != 0) {
+ digits++;
+ temp /= 10;
+ }
+
+ bytes memory buffer = new bytes(digits);
+ while (value != 0) {
+ digits -= 1;
+ buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
+ value /= 10;
+ }
+
+ return string(buffer);
+ }
+
+ function addressToString(address account) internal pure returns (string memory) {
+ return _toHexString(abi.encodePacked(account));
+ }
+
+ function _toHexString(bytes memory data) internal pure returns (string memory) {
+ bytes16 symbols = 0x30313233343536373839616263646566;
+ bytes memory str = new bytes(2 + data.length * 2);
+ str[0] = "0";
+ str[1] = "x";
+
+ for (uint256 i = 0; i < data.length; i++) {
+ str[2 + i * 2] = symbols[uint8(data[i] >> 4)];
+ str[3 + i * 2] = symbols[uint8(data[i] & 0x0f)];
+ }
+
+ return string(str);
+ }
+
+ function _authorizeUpgrade(
+ address newImplementation
+ ) internal virtual override onlyOwner {}
+}
diff --git a/src/storage/extensions/CyberCorpExtension.sol b/src/storage/extensions/CyberCorpExtension.sol
new file mode 100644
index 00000000..9ef8b182
--- /dev/null
+++ b/src/storage/extensions/CyberCorpExtension.sol
@@ -0,0 +1,107 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./ICyberCorpExtension.sol";
+import "../../libs/auth.sol";
+
+struct CyberCorpData {
+ string website;
+ string primaryBusinessLine;
+ string entityId;
+ string metadataURI;
+}
+
+contract CyberCorpExtension is UUPSUpgradeable, ICyberCorpExtension, BorgAuthACL {
+ bytes32 public constant EXTENSION_TYPE = keccak256("CYBERCORP");
+
+ uint256[30] private __gap;
+
+ function initialize(address _auth) external initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function decodeExtensionData(
+ bytes memory data
+ ) external pure returns (CyberCorpData memory) {
+ return abi.decode(data, (CyberCorpData));
+ }
+
+ function encodeExtensionData(
+ CyberCorpData memory data
+ ) external pure returns (bytes memory) {
+ return abi.encode(data);
+ }
+
+ function supportsExtensionType(
+ bytes32 extensionType
+ ) external pure override returns (bool) {
+ return extensionType == EXTENSION_TYPE;
+ }
+
+ function getExtensionURI(
+ bytes memory data
+ ) external pure override returns (string memory) {
+ CyberCorpData memory decoded = abi.decode(data, (CyberCorpData));
+
+ return string(
+ abi.encodePacked(
+ ', "CyberCorpDetails": {',
+ '"website": "',
+ decoded.website,
+ '", "primaryBusinessLine": "',
+ decoded.primaryBusinessLine,
+ '", "entityId": "',
+ decoded.entityId,
+ '", "metadataURI": "',
+ decoded.metadataURI,
+ '"}'
+ )
+ );
+ }
+
+ function _authorizeUpgrade(
+ address newImplementation
+ ) internal virtual override onlyOwner {}
+}
diff --git a/src/storage/extensions/CyberCorpExtensionV2.sol b/src/storage/extensions/CyberCorpExtensionV2.sol
new file mode 100644
index 00000000..e5629f12
--- /dev/null
+++ b/src/storage/extensions/CyberCorpExtensionV2.sol
@@ -0,0 +1,113 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./ICyberCorpExtension.sol";
+import "../../libs/auth.sol";
+
+struct CyberCorpDataV2 {
+ string website;
+ string primaryBusinessLine;
+ string entityId;
+ string metadataURI;
+ string investorRelationsURI;
+ string transferAgent;
+}
+
+contract CyberCorpExtensionV2 is UUPSUpgradeable, ICyberCorpExtension, BorgAuthACL {
+ bytes32 public constant EXTENSION_TYPE = keccak256("CYBERCORP_V2");
+
+ uint256[30] private __gap;
+
+ function initialize(address _auth) external initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function decodeExtensionData(
+ bytes memory data
+ ) external pure returns (CyberCorpDataV2 memory) {
+ return abi.decode(data, (CyberCorpDataV2));
+ }
+
+ function encodeExtensionData(
+ CyberCorpDataV2 memory data
+ ) external pure returns (bytes memory) {
+ return abi.encode(data);
+ }
+
+ function supportsExtensionType(
+ bytes32 extensionType
+ ) external pure override returns (bool) {
+ return extensionType == EXTENSION_TYPE;
+ }
+
+ function getExtensionURI(
+ bytes memory data
+ ) external pure override returns (string memory) {
+ CyberCorpDataV2 memory decoded = abi.decode(data, (CyberCorpDataV2));
+
+ return string(
+ abi.encodePacked(
+ ', "CyberCorpDetails": {',
+ '"website": "',
+ decoded.website,
+ '", "primaryBusinessLine": "',
+ decoded.primaryBusinessLine,
+ '", "entityId": "',
+ decoded.entityId,
+ '", "metadataURI": "',
+ decoded.metadataURI,
+ '", "investorRelationsURI": "',
+ decoded.investorRelationsURI,
+ '", "transferAgent": "',
+ decoded.transferAgent,
+ '"}'
+ )
+ );
+ }
+
+ function _authorizeUpgrade(
+ address newImplementation
+ ) internal virtual override onlyOwner {}
+}
diff --git a/src/storage/extensions/CyberCorpFundExtension.sol b/src/storage/extensions/CyberCorpFundExtension.sol
new file mode 100644
index 00000000..ffa8ba49
--- /dev/null
+++ b/src/storage/extensions/CyberCorpFundExtension.sol
@@ -0,0 +1,150 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bodP' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./ICyberCorpExtension.sol";
+import "../../libs/auth.sol";
+
+struct CyberCorpFundData {
+ string fundEntityType;
+ string icaExceptionRelied;
+ address transferRestrictionHookAddress;
+ string[] governingDocumentURIs;
+ string metadataURI;
+}
+
+contract CyberCorpFundExtension is
+ UUPSUpgradeable,
+ ICyberCorpExtension,
+ BorgAuthACL
+{
+ bytes32 public constant EXTENSION_TYPE = keccak256("CYBERCORP_FUND");
+
+ uint256[30] private __gap;
+
+ function initialize(address _auth) external initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function decodeExtensionData(
+ bytes memory data
+ ) external pure returns (CyberCorpFundData memory) {
+ return abi.decode(data, (CyberCorpFundData));
+ }
+
+ function encodeExtensionData(
+ CyberCorpFundData memory data
+ ) external pure returns (bytes memory) {
+ return abi.encode(data);
+ }
+
+ function supportsExtensionType(
+ bytes32 extensionType
+ ) external pure override returns (bool) {
+ return extensionType == EXTENSION_TYPE;
+ }
+
+ function getExtensionURI(
+ bytes memory data
+ ) external pure override returns (string memory) {
+ CyberCorpFundData memory decoded = abi.decode(
+ data,
+ (CyberCorpFundData)
+ );
+
+ return string(
+ abi.encodePacked(
+ ', "CyberCorpFundDetails": {',
+ '"fundEntityType": "',
+ decoded.fundEntityType,
+ '", "icaExceptionRelied": "',
+ decoded.icaExceptionRelied,
+ '", "transferRestrictionHookAddress": "',
+ addressToString(decoded.transferRestrictionHookAddress),
+ '", "governingDocumentURIs": ',
+ stringArrayToJson(decoded.governingDocumentURIs),
+ ', "metadataURI": "',
+ decoded.metadataURI,
+ '"}'
+ )
+ );
+ }
+
+ function stringArrayToJson(
+ string[] memory values
+ ) internal pure returns (string memory) {
+ string memory json = "[";
+
+ for (uint256 i = 0; i < values.length; i++) {
+ if (i > 0) {
+ json = string.concat(json, ", ");
+ }
+ json = string.concat(json, '"', values[i], '"');
+ }
+
+ return string.concat(json, "]");
+ }
+
+ function addressToString(address account) internal pure returns (string memory) {
+ return _toHexString(abi.encodePacked(account));
+ }
+
+ function _toHexString(bytes memory data) internal pure returns (string memory) {
+ bytes16 symbols = 0x30313233343536373839616263646566;
+ bytes memory str = new bytes(2 + data.length * 2);
+ str[0] = "0";
+ str[1] = "x";
+
+ for (uint256 i = 0; i < data.length; i++) {
+ str[2 + i * 2] = symbols[uint8(data[i] >> 4)];
+ str[3 + i * 2] = symbols[uint8(data[i] & 0x0f)];
+ }
+
+ return string(str);
+ }
+
+ function _authorizeUpgrade(
+ address newImplementation
+ ) internal virtual override onlyOwner {}
+}
diff --git a/src/storage/extensions/FundInterestExtension.sol b/src/storage/extensions/FundInterestExtension.sol
new file mode 100644
index 00000000..e4743a99
--- /dev/null
+++ b/src/storage/extensions/FundInterestExtension.sol
@@ -0,0 +1,104 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
+import "./ICertificateExtension.sol";
+import "../../libs/auth.sol";
+
+/// @notice Per-certificate fund-interest data (spec §12B.3). The two dates drive the Rule 144 holding
+/// period (HoldingPeriodCondition) and the Reg S distribution compliance period
+/// (RegSDistributionComplianceCondition).
+struct FundInterestData {
+ uint64 acquisitionDate; // when the holder acquired the interest (validity anchor for holds)
+ uint64 tackedFromAcquisitionDate; // Rule 144(d)(3) tacking anchor; 0 = no tacking asserted
+ string customProvisions;
+}
+
+/// @title FundInterestExtension - certificate extension for SPV fund interests
+/// @author MetaLeX Labs, Inc.
+contract FundInterestExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL {
+ bytes32 public constant EXTENSION_TYPE = keccak256("FUND_INTEREST");
+
+ //offset to leave for future upgrades
+ uint256[30] private __gap;
+
+ function initialize(address _auth) external initializer {
+ __UUPSUpgradeable_init();
+ __BorgAuthACL_init(_auth);
+ }
+
+ function decodeExtensionData(bytes memory data) external pure returns (FundInterestData memory) {
+ return abi.decode(data, (FundInterestData));
+ }
+
+ function encodeExtensionData(FundInterestData memory data) external pure returns (bytes memory) {
+ return abi.encode(data);
+ }
+
+ function supportsExtensionType(bytes32 extensionType) external pure override returns (bool) {
+ return extensionType == EXTENSION_TYPE;
+ }
+
+ function getExtensionURI(bytes memory data) external view override returns (string memory) {
+ FundInterestData memory decoded = abi.decode(data, (FundInterestData));
+
+ return string(
+ abi.encodePacked(
+ ', "FundInterestDetails": {',
+ '"acquisitionDate": "',
+ uint256ToString(decoded.acquisitionDate),
+ '", "tackedFromAcquisitionDate": "',
+ uint256ToString(decoded.tackedFromAcquisitionDate),
+ '", "customProvisions": "',
+ decoded.customProvisions,
+ '"}'
+ )
+ );
+ }
+
+ function uint256ToString(uint256 value) internal pure returns (string memory) {
+ if (value == 0) {
+ return "0";
+ }
+ uint256 temp = value;
+ uint256 digits;
+ while (temp != 0) {
+ digits++;
+ temp /= 10;
+ }
+ bytes memory buffer = new bytes(digits);
+ while (value != 0) {
+ digits -= 1;
+ buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
+ value /= 10;
+ }
+ return string(buffer);
+ }
+
+ function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {}
+}
diff --git a/src/storage/extensions/ICyberCorpExtension.sol b/src/storage/extensions/ICyberCorpExtension.sol
new file mode 100644
index 00000000..0baec95c
--- /dev/null
+++ b/src/storage/extensions/ICyberCorpExtension.sol
@@ -0,0 +1,47 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+
+pragma solidity 0.8.28;
+
+interface ICyberCorpExtension {
+ function supportsExtensionType(bytes32 extensionType) external pure returns (bool);
+ function getExtensionURI(bytes memory data) external view returns (string memory);
+}
diff --git a/test/CyberCertPrinterTest.t.sol b/test/CyberCertPrinterTest.t.sol
index 9302b0c2..8d4bd60b 100644
--- a/test/CyberCertPrinterTest.t.sol
+++ b/test/CyberCertPrinterTest.t.sol
@@ -2,9 +2,807 @@
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
+import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {CertificateUriBuilder} from "../src/CertificateUriBuilder.sol";
+import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
+import {CyberCertPrinterStorage} from "../src/storage/CyberCertPrinterStorage.sol";
+import {SecurityClass, SecuritySeries} from "../src/CyberCorpConstants.sol";
+import {IUriBuilder} from "../src/interfaces/IUriBuilder.sol";
+import {
+ CertificateDetails,
+ Endorsement,
+ OwnerDetails,
+ RestrictionType,
+ RestrictiveLegend
+} from "../src/interfaces/ICyberCertPrinter.sol";
+
+contract MockCyberCorp {
+ address public dealManager;
+ address public roundManager;
+
+ constructor(address _dealManager, address _roundManager) {
+ dealManager = _dealManager;
+ roundManager = _roundManager;
+ }
+
+ function cyberCORPName() external pure returns (string memory) {
+ return "Mock Corp";
+ }
+
+ function cyberCORPType() external pure returns (string memory) {
+ return "C-Corp";
+ }
+
+ function cyberCORPJurisdiction() external pure returns (string memory) {
+ return "DE";
+ }
+
+ function cyberCORPContactDetails() external pure returns (string memory) {
+ return "mock@example.com";
+ }
+}
+
+contract MockIssuanceManager {
+ address public immutable CORP;
+ address public immutable uriBuilder;
+
+ constructor(address corp, address builder) {
+ CORP = corp;
+ uriBuilder = builder;
+ }
+
+ function companyName() external pure returns (string memory) {
+ return "Mock Corp";
+ }
+
+ function getCertScripifiedStatus(
+ address,
+ uint256
+ ) external pure returns (bool isScripified, uint256 scripifiedUnits, uint256 maxUnitsRepresented) {
+ return (false, 0, 0);
+ }
+}
+
+contract MockUriBuilder is IUriBuilder {
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ string[] memory certLegend,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return certLegend.length == 0 ? "legacy-empty" : string.concat("legacy:", certLegend[0]);
+ }
+
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ if (certLegend.length == 0) return "structured-empty";
+ return string.concat(
+ _restrictionTypeToString(certLegend[0].restrictionType),
+ "|",
+ certLegend[0].title,
+ "|",
+ certLegend[0].text
+ );
+ }
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ string[] memory certLegend,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return certLegend.length == 0 ? "legacy-empty" : string.concat("legacy:", certLegend[0]);
+ }
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory certLegend,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ if (certLegend.length == 0) return "structured-empty";
+ return string.concat(
+ _restrictionTypeToString(certLegend[0].restrictionType),
+ "|",
+ certLegend[0].title,
+ "|",
+ certLegend[0].text
+ );
+ }
+
+ function _restrictionTypeToString(RestrictionType restrictionType) private pure returns (string memory) {
+ if (restrictionType == RestrictionType.RegulationS) return "RegulationS";
+ if (restrictionType == RestrictionType.Custom) return "Custom";
+ return "Other";
+ }
+}
+
+/// @dev Test-only subclass exposing a hook to recreate the pre-enumeration ("legacy") storage state directly —
+/// owners[] populated but the legal-owner enumeration empty — so tests don't have to vm.store into the diamond
+/// storage by hand. It only ADDS a function; all production CyberCertPrinter logic is inherited unchanged.
+contract CyberCertPrinterEnhanced is CyberCertPrinter {
+ /// @dev Clear the legal-owner enumeration for `owner`/`tokenIds`, mimicking certs minted before the
+ /// enumeration existed (owners[] stays; count/index/tracked are zeroed).
+ function debugClearLegalOwnerEnumeration(address owner, uint256[] calldata tokenIds) external {
+ CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage();
+ for (uint256 i = 0; i < tokenIds.length; i++) {
+ uint256 tokenId = tokenIds[i];
+ delete s.legalOwnedTokens[owner][s.legalOwnedTokensIndex[tokenId]];
+ delete s.legalOwnedTokensIndex[tokenId];
+ s.legalOwnedTokenTracked[tokenId] = false;
+ }
+ s.legalOwnerTokenCount[owner] = 0;
+ }
+}
contract CyberCertPrinterTest is Test {
- function test_UpgradeCyberCertPrinter() public {
- // TODO WIP
+ CyberCertPrinter private printer;
+ MockIssuanceManager private issuanceManager;
+
+ address private investor = address(0xA11CE);
+ address private recipient = address(0xB0B);
+ address private initialExtension = address(0xE100);
+ address private updatedExtension = address(0xE200);
+
+ event CertificateSigned(uint256 indexed tokenId, bytes signature);
+ event GlobalTransferableSet(bool indexed transferable);
+
+ function setUp() public {
+ MockCyberCorp corp = new MockCyberCorp(address(0xDE1), address(0xA0));
+ MockUriBuilder uriBuilder = new MockUriBuilder();
+ issuanceManager = new MockIssuanceManager(address(corp), address(uriBuilder));
+
+ string[] memory defaultLegend = new string[](1);
+ defaultLegend[0] = "Default legend";
+
+ CyberCertPrinter implementation = new CyberCertPrinterEnhanced();
+ bytes memory initData = abi.encodeCall(
+ CyberCertPrinter.initialize,
+ (
+ defaultLegend,
+ "Mock Cert",
+ "MCERT",
+ "ipfs://certificate",
+ address(issuanceManager),
+ SecurityClass.PreferredStock,
+ SecuritySeries.SeriesA,
+ initialExtension
+ )
+ );
+ printer = CyberCertPrinter(address(new ERC1967Proxy(address(implementation), initData)));
+ }
+
+ function test_AddIssuerSignature_StoresSignatureAndEmitsEvent() public {
+ _mintCert(1, investor, 100, bytes("series-a-data"));
+
+ bytes memory signature = hex"123456";
+
+ vm.expectEmit(true, false, false, true);
+ emit CertificateSigned(1, signature);
+
+ vm.prank(address(issuanceManager));
+ printer.addIssuerSignature(1, signature);
+
+ assertEq(printer.getIssuerSignatureCount(1), 1);
+ assertEq(printer.getIssuerSignatureAt(1, 0), signature);
+ }
+
+ function test_AddIssuerSignature_RevertsForEmptySignature() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.SignatureRequired.selector);
+ printer.addIssuerSignature(1, "");
+ }
+
+ function test_AddIssuerSignature_RevertsForNonexistentToken() public {
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.TokenDoesNotExist.selector);
+ printer.addIssuerSignature(999, hex"123456");
+ }
+
+ function test_AddIssuerSignature_RevertsWhenCallerIsNotIssuanceManager() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(investor);
+ vm.expectRevert(CyberCertPrinter.NotIssuanceManager.selector);
+ printer.addIssuerSignature(1, hex"123456");
+ }
+
+ function test_GettersExposeConfiguredExtensionAndStoredExtensionData() public {
+ bytes memory extensionData = abi.encode("Series Seed Preferred", uint256(10_000_000));
+ _mintCert(1, investor, 100, extensionData);
+
+ assertEq(printer.getExtension(1), initialExtension);
+ assertEq(printer.getExtensionData(1), extensionData);
+ }
+
+ function test_SetExtension_UpdatesGlobalExtensionFromIssuanceManager() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setExtension(999, updatedExtension);
+
+ assertEq(printer.getExtension(1), updatedExtension);
+ }
+
+ function test_SetExtension_RevertsWhenCallerIsNotIssuanceManager() public {
+ vm.prank(investor);
+ vm.expectRevert(CyberCertPrinter.NotIssuanceManager.selector);
+ printer.setExtension(1, updatedExtension);
+ }
+
+ function test_UnitsReserved_IncreaseAndDecreaseWithinCertificateUnits() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.increaseUnitsReserved(1, 40);
+ assertEq(printer.unitsReserved(1), 40);
+
+ vm.prank(address(issuanceManager));
+ printer.decreaseUnitsReserved(1, 15);
+ assertEq(printer.unitsReserved(1), 25);
+ }
+
+ function test_UnitsReserved_RevertsWhenIncreaseExceedsUnitsRepresented() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.ExceedsAvailableUnits.selector);
+ printer.increaseUnitsReserved(1, 101);
+ }
+
+ function test_UnitsReserved_RevertsWhenDecreaseExceedsReservedUnits() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.increaseUnitsReserved(1, 40);
+
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.ExceedsReservedUnits.selector);
+ printer.decreaseUnitsReserved(1, 41);
+ }
+
+ function test_UnitsReserved_RevertsForNonexistentTokens() public {
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.TokenDoesNotExist.selector);
+ printer.increaseUnitsReserved(999, 1);
+
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.TokenDoesNotExist.selector);
+ printer.decreaseUnitsReserved(999, 1);
+ }
+
+ function test_ReservedCert_BlocksTransferUntilReleased() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setTokenTransferable(1, true);
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+
+ // Reserving units escrows the cert; its legal ownership is frozen while any units are reserved.
+ vm.prank(address(issuanceManager));
+ printer.increaseUnitsReserved(1, 1);
+
+ vm.prank(investor);
+ vm.expectRevert(CyberCertPrinter.CertificateReserved.selector);
+ printer.transferFrom(investor, recipient, 1);
+
+ // Releasing the reservation (settlement/void) unfreezes the transfer.
+ vm.prank(address(issuanceManager));
+ printer.decreaseUnitsReserved(1, 1);
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+ assertEq(printer.legalOwnerOf(1), recipient);
+ }
+
+ function test_TokenTransferable_AllowsOneTokenWithoutEnablingGlobalTransfers() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setTokenTransferable(1, true);
+
+ assertTrue(printer.isTokenTransferable(1));
+ assertFalse(printer.isTokenTransferable(2));
+
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+ assertEq(printer.ownerOf(1), recipient);
+ assertEq(printer.legalOwnerOf(1), recipient);
+
+ vm.prank(investor);
+ vm.expectRevert(CyberCertPrinter.TokenNotTransferable.selector);
+ printer.transferFrom(investor, recipient, 2);
+ }
+
+ function test_TokenTransferable_RevertsWhenCallerIsNotIssuanceManager() public {
+ vm.prank(investor);
+ vm.expectRevert(CyberCertPrinter.NotIssuanceManager.selector);
+ printer.setTokenTransferable(1, true);
+ }
+
+ function test_Initialize_SetsCertificateConfiguration() public view {
+ assertEq(printer.name(), "Mock Cert");
+ assertEq(printer.symbol(), "MCERT");
+ assertEq(printer.certificateUri(), "ipfs://certificate");
+ assertEq(printer.issuanceManager(), address(issuanceManager));
+ assertEq(uint8(printer.securityType()), uint8(SecurityClass.PreferredStock));
+ assertEq(uint8(printer.securitySeries()), uint8(SecuritySeries.SeriesA));
+ assertEq(printer.getExtension(0), initialExtension);
+ assertEq(printer.endorsementRequired(), true);
+ }
+
+ function test_SafeMint_CopiesDefaultLegendAndSetsLegalOwner() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ assertEq(printer.ownerOf(1), investor);
+ assertEq(printer.legalOwnerOf(1), investor);
+ assertEq(printer.getCertLegendCount(1), 1);
+ assertEq(printer.getCertLegendAt(1, 0), "Default legend");
+ }
+
+ function test_HolderCount_TracksUniqueHoldersOnMint() public {
+ assertEq(printer.holderCount(), 0);
+
+ _mintCert(1, investor, 100, bytes(""));
+ assertEq(printer.holderCount(), 1);
+
+ _mintCert(2, investor, 100, bytes(""));
+ assertEq(printer.holderCount(), 1);
+
+ _mintCert(3, recipient, 100, bytes(""));
+ assertEq(printer.holderCount(), 2);
+ }
+
+ function test_HolderCount_TracksUniqueHoldersOnTransfers() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+ assertEq(printer.holderCount(), 2);
+
+ vm.prank(investor);
+ printer.addEndorsement(2, _endorsement(investor, recipient));
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 2);
+ assertEq(printer.holderCount(), 1);
+ }
+
+ function test_HolderCount_TransferBetweenExistingHoldersDoesNotIncreaseCount() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, recipient, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+
+ assertEq(printer.holderCount(), 1);
+ }
+
+ function test_HolderCount_SelfTransferDoesNotChangeCount() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+
+ vm.prank(investor);
+ printer.transferFrom(investor, investor, 1);
+
+ assertEq(printer.holderCount(), 1);
+ }
+
+ function test_HolderCount_TracksUniqueHoldersOnBurn() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+ assertEq(printer.holderCount(), 1);
+
+ vm.prank(address(issuanceManager));
+ printer.burn(1);
+ assertEq(printer.holderCount(), 1);
+
+ vm.prank(address(issuanceManager));
+ printer.burn(2);
+ assertEq(printer.holderCount(), 0);
+ }
+
+ function test_SafeMintAndAssign_StoresNamedLegalOwner() public {
+ vm.prank(address(issuanceManager));
+ printer.safeMintAndAssign(investor, 1, _details(100, bytes("")), "Alice Investor");
+
+ assertEq(printer.ownerOf(1), investor);
+ assertEq(printer.legalOwnerOf(1), investor);
+ assertEq(printer.getCertLegendAt(1, 0), "Default legend");
+ }
+
+ // ── Legal-owner enumeration: backward compatibility for legacy printers ──
+
+ // Re-running the backfill on a printer that already tracks every token is a no-op (idempotent), never
+ // double-counting.
+ function test_BackfillLegalOwners_IdempotentOnTrackedPrinter() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+ _mintCert(3, recipient, 100, bytes(""));
+
+ printer.backfillLegalOwners(0, printer.totalSupply());
+ printer.backfillLegalOwners(0, 100); // over-wide range clamps to supply
+
+ assertEq(printer.balanceOfLegalOwner(investor), 2);
+ assertEq(printer.balanceOfLegalOwner(recipient), 1);
+ assertEq(printer.tokenOfLegalOwnerByIndex(investor, 0), 1);
+ assertEq(printer.tokenOfLegalOwnerByIndex(investor, 1), 2);
+ }
+
+ // A legacy token (enumeration never populated) must not underflow legalOwnerTokenCount when it is burned.
+ function test_LegalOwnerEnumeration_LegacyBurnDoesNotUnderflow() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+ uint256[] memory ids = new uint256[](2);
+ ids[0] = 1;
+ ids[1] = 2;
+ CyberCertPrinterEnhanced(address(printer)).debugClearLegalOwnerEnumeration(investor, ids);
+ assertEq(printer.balanceOfLegalOwner(investor), 0, "legacy enumeration starts empty");
+
+ vm.prank(address(issuanceManager));
+ printer.burn(1); // must not revert (guarded no-op remove)
+
+ assertEq(printer.balanceOfLegalOwner(investor), 0);
+ }
+
+ // An owner-write (endorsed transfer) on a legacy token lazily backfills it under the new owner — and the
+ // implicit remove from the old owner is a safe no-op (no underflow).
+ function test_LegalOwnerEnumeration_LegacyOwnerWriteLazilyBackfills() public {
+ _mintCert(1, investor, 100, bytes(""));
+ uint256[] memory ids = new uint256[](1);
+ ids[0] = 1;
+ CyberCertPrinterEnhanced(address(printer)).debugClearLegalOwnerEnumeration(investor, ids);
+ assertEq(printer.balanceOfLegalOwner(investor), 0);
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+
+ assertEq(printer.legalOwnerOf(1), recipient);
+ assertEq(printer.balanceOfLegalOwner(recipient), 1, "lazily tracked under new owner");
+ assertEq(printer.tokenOfLegalOwnerByIndex(recipient, 0), 1);
+ assertEq(printer.balanceOfLegalOwner(investor), 0, "old owner stays empty, no underflow add");
+ }
+
+ // The explicit batched backfill repopulates the enumeration from owners[] for all live tokens, in batches,
+ // and is safe to re-run.
+ function test_BackfillLegalOwners_RepopulatesLegacyEnumeration() public {
+ _mintCert(1, investor, 100, bytes(""));
+ _mintCert(2, investor, 100, bytes(""));
+ _mintCert(3, investor, 100, bytes(""));
+ uint256[] memory ids = new uint256[](3);
+ ids[0] = 1;
+ ids[1] = 2;
+ ids[2] = 3;
+ CyberCertPrinterEnhanced(address(printer)).debugClearLegalOwnerEnumeration(investor, ids);
+ assertEq(printer.balanceOfLegalOwner(investor), 0);
+
+ printer.backfillLegalOwners(0, 2);
+ assertEq(printer.balanceOfLegalOwner(investor), 2);
+ printer.backfillLegalOwners(2, 10); // clamps to supply
+ assertEq(printer.balanceOfLegalOwner(investor), 3);
+
+ printer.backfillLegalOwners(0, 3); // re-run safe
+ assertEq(printer.balanceOfLegalOwner(investor), 3);
+ assertEq(printer.tokenOfLegalOwnerByIndex(investor, 0), 1);
+ assertEq(printer.tokenOfLegalOwnerByIndex(investor, 2), 3);
+ }
+
+ function test_AddDefaultRestrictiveLegend_StoresAndCopiesOnMint() public {
+ RestrictiveLegend memory legend = _legend(
+ RestrictionType.RegulationS,
+ "Reg S Legend",
+ "Transfer only offshore",
+ "US",
+ true
+ );
+
+ vm.prank(address(issuanceManager));
+ printer.addDefaultRestrictiveLegend(legend);
+
+ assertEq(printer.getDefaultRestrictiveLegendCount(), 1);
+ RestrictiveLegend memory storedDefault = printer.getDefaultRestrictiveLegendAt(0);
+ assertEq(uint8(storedDefault.restrictionType), uint8(RestrictionType.RegulationS));
+ assertEq(storedDefault.title, "Reg S Legend");
+ assertEq(storedDefault.text, "Transfer only offshore");
+ assertEq(storedDefault.jurisdiction, "US");
+ assertTrue(storedDefault.active);
+
+ _mintCert(1, investor, 100, bytes(""));
+
+ assertEq(printer.getCertRestrictiveLegendCount(1), 1);
+ RestrictiveLegend memory storedCert = printer.getCertRestrictiveLegendAt(1, 0);
+ assertEq(uint8(storedCert.restrictionType), uint8(RestrictionType.RegulationS));
+ assertEq(storedCert.title, "Reg S Legend");
+ assertEq(storedCert.text, "Transfer only offshore");
+ }
+
+ function test_AddAndRemoveCertRestrictiveLegend() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.addCertRestrictiveLegend(
+ 1,
+ _legend(RestrictionType.Custom, "Custom Legend", "Board approval required", "DE", true)
+ );
+
+ assertEq(printer.getCertRestrictiveLegendCount(1), 1);
+ RestrictiveLegend memory stored = printer.getCertRestrictiveLegendAt(1, 0);
+ assertEq(stored.text, "Board approval required");
+
+ vm.prank(address(issuanceManager));
+ printer.removeCertRestrictiveLegendAt(1, 0);
+
+ assertEq(printer.getCertRestrictiveLegendCount(1), 0);
+ }
+
+ function test_TokenURI_UsesStructuredRestrictiveLegend() public {
+ vm.prank(address(issuanceManager));
+ printer.addDefaultRestrictiveLegend(
+ _legend(RestrictionType.RegulationS, "Reg S Legend", "Transfer only offshore", "US", true)
+ );
+ _mintCert(1, investor, 100, bytes(""));
+
+ assertEq(printer.tokenURI(1), "RegulationS|Reg S Legend|Transfer only offshore");
+ }
+
+ function test_TokenURI_FallsBackToLegacyLegendAsCustom() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ assertEq(printer.tokenURI(1), "Custom||Default legend");
+ }
+
+ function test_CertificateUriBuilder_RendersStructuredRestrictiveLegends() public {
+ CertificateUriBuilder builder = new CertificateUriBuilder();
+ RestrictiveLegend[] memory legends = new RestrictiveLegend[](1);
+ legends[0] = _legend(
+ RestrictionType.RegulationS,
+ "Reg S Legend",
+ "Transfer only offshore",
+ "US",
+ true
+ );
+ legends[0].data = hex"1234";
+
+ assertEq(
+ builder.restrictiveLegendsToJson(legends),
+ string.concat(
+ '[{"id": 1, "restrictionType": "RegulationS", "title": "Reg S Legend", ',
+ '"text": "Transfer only offshore", "jurisdiction": "US", ',
+ '"referenceId": "0x0000000000000000000000000000000000000000000000000000000000000000", ',
+ '"effectiveTimestamp": "0", "expirationTimestamp": "0", "active": "true", "data": "0x1234"}]'
+ )
+ );
+ }
+
+ function test_UpdateCertificateDetails_ReplacesStoredDetails() public {
+ _mintCert(1, investor, 100, bytes("initial"));
+
+ CertificateDetails memory updated = _details(250, bytes("updated"));
+ vm.prank(address(issuanceManager));
+ printer.updateCertificateDetails(1, updated);
+
+ CertificateDetails memory stored = printer.getCertificateDetails(1);
+ assertEq(stored.unitsRepresented, 250);
+ assertEq(stored.extensionData, bytes("updated"));
+ }
+
+ // Chokepoint invariant: raw unitsRepresented may never be written below the units locked in pending deals.
+ function test_UpdateCertificateDetails_RevertsWhenBelowReserved() public {
+ _mintCert(1, investor, 100, bytes(""));
+ vm.prank(address(issuanceManager));
+ printer.increaseUnitsReserved(1, 40);
+
+ CertificateDetails memory updated = _details(39, bytes(""));
+ vm.prank(address(issuanceManager));
+ vm.expectRevert(CyberCertPrinter.ExceedsAvailableUnits.selector);
+ printer.updateCertificateDetails(1, updated);
+ }
+
+ // Boundary: lowering exactly to the reserved amount is allowed (guard is strict `<`, not `<=`).
+ function test_UpdateCertificateDetails_AllowsLoweringToReserved() public {
+ _mintCert(1, investor, 100, bytes(""));
+ vm.prank(address(issuanceManager));
+ printer.increaseUnitsReserved(1, 40);
+
+ CertificateDetails memory updated = _details(40, bytes(""));
+ vm.prank(address(issuanceManager));
+ printer.updateCertificateDetails(1, updated);
+
+ assertEq(printer.getActiveCertificateDetails(1).unitsRepresented, 40);
+ }
+
+ function test_GetActiveCertificateDetails_ReturnsStoredDetails() public {
+ _mintCert(1, investor, 123, bytes("active"));
+
+ CertificateDetails memory details = printer.getActiveCertificateDetails(1);
+
+ assertEq(details.unitsRepresented, 123);
+ assertEq(details.extensionData, bytes("active"));
+ }
+
+ function test_AddIssuerSignature_AppendsMultipleSignaturesInOrder() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.addIssuerSignature(1, hex"aaaa");
+
+ vm.prank(address(issuanceManager));
+ printer.addIssuerSignature(1, hex"bbbbcc");
+
+ assertEq(printer.getIssuerSignatureCount(1), 2);
+ assertEq(printer.getIssuerSignatureAt(1, 0), hex"aaaa");
+ assertEq(printer.getIssuerSignatureAt(1, 1), hex"bbbbcc");
+ }
+
+ function test_GetIssuerSignatureCount_RevertsForNonexistentToken() public {
+ vm.expectRevert(CyberCertPrinter.TokenDoesNotExist.selector);
+ printer.getIssuerSignatureCount(999);
+ }
+
+ function test_GetIssuerSignatureAt_RevertsForNonexistentToken() public {
+ vm.expectRevert(CyberCertPrinter.TokenDoesNotExist.selector);
+ printer.getIssuerSignatureAt(999, 0);
+ }
+
+ function test_SetGlobalTransferable_EmitsAndUpdatesFlag() public {
+ vm.expectEmit(true, false, false, true);
+ emit GlobalTransferableSet(true);
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+
+ assertTrue(printer.transferable());
+ }
+
+ function test_GlobalTransferable_AllowsTransferWithEndorsement() public {
+ _mintCert(1, investor, 100, bytes(""));
+
+ vm.prank(address(issuanceManager));
+ printer.setGlobalTransferable(true);
+
+ vm.prank(investor);
+ printer.addEndorsement(1, _endorsement(investor, recipient));
+
+ vm.prank(investor);
+ printer.transferFrom(investor, recipient, 1);
+
+ assertEq(printer.ownerOf(1), recipient);
+ assertEq(printer.legalOwnerOf(1), recipient);
+ }
+
+ function _mintCert(
+ uint256 tokenId,
+ address to,
+ uint256 unitsRepresented,
+ bytes memory extensionData
+ ) private {
+ vm.prank(address(issuanceManager));
+ printer.safeMint(tokenId, to, _details(unitsRepresented, extensionData));
+ }
+
+ function _details(
+ uint256 unitsRepresented,
+ bytes memory extensionData
+ ) private pure returns (CertificateDetails memory) {
+ return
+ CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "CEO",
+ investmentAmountUSD: 1_000,
+ issuerUSDValuationAtTimeOfInvestment: 10_000,
+ unitsRepresented: unitsRepresented,
+ legalDetails: "Legal details",
+ extensionData: extensionData
+ });
+ }
+
+ function _legend(
+ RestrictionType restrictionType,
+ string memory title,
+ string memory text,
+ string memory jurisdiction,
+ bool active
+ ) private pure returns (RestrictiveLegend memory) {
+ return RestrictiveLegend({
+ restrictionType: restrictionType,
+ title: title,
+ text: text,
+ jurisdiction: jurisdiction,
+ referenceId: bytes32(0),
+ effectiveTimestamp: 0,
+ expirationTimestamp: 0,
+ active: active,
+ data: ""
+ });
+ }
+
+ function _endorsement(
+ address endorser,
+ address endorsee
+ ) private view returns (Endorsement memory) {
+ return
+ Endorsement({
+ endorser: endorser,
+ timestamp: block.timestamp,
+ signatureHash: hex"abcd",
+ registry: address(0),
+ agreementId: bytes32(0),
+ endorsee: endorsee,
+ endorseeName: "Recipient"
+ });
}
}
diff --git a/test/CyberCorpExtensionTest.t.sol b/test/CyberCorpExtensionTest.t.sol
new file mode 100644
index 00000000..d3e97e52
--- /dev/null
+++ b/test/CyberCorpExtensionTest.t.sol
@@ -0,0 +1,352 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity 0.8.28;
+
+import {Test} from "forge-std/Test.sol";
+import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {BorgAuth} from "../src/libs/auth.sol";
+import {CyberCorp} from "../src/CyberCorp.sol";
+import {CompanyOfficer} from "../src/CyberCorpConstants.sol";
+import {
+ CyberCorpExtension,
+ CyberCorpData
+} from "../src/storage/extensions/CyberCorpExtension.sol";
+import {
+ CyberCorpExtensionV2,
+ CyberCorpDataV2
+} from "../src/storage/extensions/CyberCorpExtensionV2.sol";
+import {
+ CyberCorpComplianceExtension,
+ CyberCorpComplianceData,
+ FeeDetail
+} from "../src/storage/extensions/CyberCorpComplianceExtension.sol";
+import {
+ CyberCorpFundExtension,
+ CyberCorpFundData
+} from "../src/storage/extensions/CyberCorpFundExtension.sol";
+
+contract CyberCorpExtensionTest is Test {
+ address internal owner;
+ BorgAuth internal auth;
+ CyberCorp internal cyberCorp;
+
+ function setUp() public {
+ owner = makeAddr("owner");
+ auth = new BorgAuth(owner);
+
+ CompanyOfficer memory officer = CompanyOfficer({
+ eoa: owner,
+ name: "Owner",
+ contact: "owner@corp.test",
+ title: "CEO"
+ });
+
+ CyberCorp implementation = new CyberCorp();
+ cyberCorp = CyberCorp(
+ address(
+ new ERC1967Proxy(
+ address(implementation),
+ abi.encodeWithSelector(
+ CyberCorp.initialize.selector,
+ address(auth),
+ "CyberCorp",
+ "Corporation",
+ "Delaware",
+ "contact@corp.test",
+ "AAA",
+ address(0xBEEF),
+ owner,
+ officer,
+ address(0xCAFE),
+ address(0)
+ )
+ )
+ )
+ );
+ }
+
+ function test_SetCyberCorpExtensionDataWithVersionedSchema() public {
+ CyberCorpExtension corpExtension = new CyberCorpExtension();
+ corpExtension.initialize(address(auth));
+
+ bytes memory encoded = corpExtension.encodeExtensionData(
+ CyberCorpData({
+ website: "https://corp.test",
+ primaryBusinessLine: "Software",
+ entityId: "DE-12345",
+ metadataURI: "ipfs://corp-metadata"
+ })
+ );
+
+ vm.startPrank(owner);
+ cyberCorp.setExtension(
+ address(corpExtension),
+ corpExtension.EXTENSION_TYPE()
+ );
+ cyberCorp.setExtensionData(encoded);
+ vm.stopPrank();
+
+ assertEq(cyberCorp.extension(), address(corpExtension));
+ assertEq(cyberCorp.extensionType(), corpExtension.EXTENSION_TYPE());
+ assertEq(cyberCorp.extensionData(), encoded);
+ assertEq(
+ cyberCorp.getExtensionURI(),
+ corpExtension.getExtensionURI(encoded)
+ );
+ }
+
+ function test_RevertIf_ExtensionTypeUnsupported() public {
+ CyberCorpExtension corpExtension = new CyberCorpExtension();
+ corpExtension.initialize(address(auth));
+
+ vm.prank(owner);
+ vm.expectRevert(CyberCorp.ExtensionTypeNotSupported.selector);
+ cyberCorp.setExtension(
+ address(corpExtension),
+ keccak256("CYBERCORP_V2")
+ );
+ }
+
+ function test_SettingNewExtensionClearsStaleExtensionData() public {
+ CyberCorpExtension corpExtension = new CyberCorpExtension();
+ corpExtension.initialize(address(auth));
+
+ CyberCorpExtensionV2 corpExtensionV2 = new CyberCorpExtensionV2();
+ corpExtensionV2.initialize(address(auth));
+
+ bytes memory encodedV1 = corpExtension.encodeExtensionData(
+ CyberCorpData({
+ website: "https://corp.test",
+ primaryBusinessLine: "Software",
+ entityId: "DE-12345",
+ metadataURI: "ipfs://corp-metadata"
+ })
+ );
+
+ bytes memory encodedV2 = corpExtensionV2.encodeExtensionData(
+ CyberCorpDataV2({
+ website: "https://corp.test",
+ primaryBusinessLine: "Software",
+ entityId: "DE-12345",
+ metadataURI: "ipfs://corp-metadata-v2",
+ investorRelationsURI: "https://corp.test/ir",
+ transferAgent: "Transfer Agent LLC"
+ })
+ );
+
+ vm.startPrank(owner);
+ cyberCorp.setExtension(
+ address(corpExtension),
+ corpExtension.EXTENSION_TYPE()
+ );
+ cyberCorp.setExtensionData(encodedV1);
+ cyberCorp.setExtension(
+ address(corpExtensionV2),
+ corpExtensionV2.EXTENSION_TYPE()
+ );
+ vm.stopPrank();
+
+ assertEq(cyberCorp.extension(), address(corpExtensionV2));
+ assertEq(cyberCorp.extensionType(), corpExtensionV2.EXTENSION_TYPE());
+ assertEq(cyberCorp.extensionData().length, 0);
+
+ vm.prank(owner);
+ cyberCorp.setExtensionData(encodedV2);
+
+ assertEq(cyberCorp.extensionData(), encodedV2);
+ assertEq(
+ cyberCorp.getExtensionURI(),
+ corpExtensionV2.getExtensionURI(encodedV2)
+ );
+ }
+
+ function test_SetComplianceExtensionDataWithErisaOwnershipFeesAndRestrictions() public {
+ CyberCorpComplianceExtension complianceExtension =
+ new CyberCorpComplianceExtension();
+ complianceExtension.initialize(address(auth));
+
+ string[] memory holderRestrictions = new string[](3);
+ holderRestrictions[0] = "No sanctioned persons";
+ holderRestrictions[1] = "Transfers to non-U.S. persons require review";
+ holderRestrictions[2] = "Sensitive sector holders may require CFIUS review";
+
+ FeeDetail[] memory feeDetails = new FeeDetail[](2);
+ feeDetails[0] = FeeDetail({
+ feeName: "Platform fee",
+ feeBps: 250,
+ flatFee: 0,
+ recipient: address(0xFEE1),
+ feeToken: "USDC",
+ notes: "Charged on primary issuance proceeds"
+ });
+ feeDetails[1] = FeeDetail({
+ feeName: "Transfer admin fee",
+ feeBps: 0,
+ flatFee: 500e6,
+ recipient: address(0xFEE2),
+ feeToken: "USDC",
+ notes: "Flat fee per approved secondary transfer"
+ });
+
+ CyberCorpComplianceData memory complianceData = CyberCorpComplianceData({
+ erisaAllowed: false,
+ maxOwnershipBps: 1500,
+ minNonZeroOwnershipBps: 5,
+ maxHolderCount: 1999,
+ cfiusApprovalRequired: true,
+ holderRestrictions: holderRestrictions,
+ feeDetails: feeDetails
+ });
+
+ bytes memory encoded =
+ complianceExtension.encodeExtensionData(complianceData);
+
+ vm.startPrank(owner);
+ cyberCorp.setExtension(
+ address(complianceExtension),
+ complianceExtension.EXTENSION_TYPE()
+ );
+ cyberCorp.setExtensionData(encoded);
+ vm.stopPrank();
+
+ CyberCorpComplianceData memory decoded =
+ complianceExtension.decodeExtensionData(cyberCorp.extensionData());
+
+ assertEq(cyberCorp.extension(), address(complianceExtension));
+ assertEq(
+ cyberCorp.extensionType(),
+ complianceExtension.EXTENSION_TYPE()
+ );
+ assertEq(decoded.erisaAllowed, false);
+ assertEq(decoded.maxOwnershipBps, 1500);
+ assertEq(decoded.minNonZeroOwnershipBps, 5);
+ assertEq(decoded.maxHolderCount, 1999);
+ assertEq(decoded.cfiusApprovalRequired, true);
+ assertEq(decoded.holderRestrictions.length, 3);
+ assertEq(decoded.holderRestrictions[2], holderRestrictions[2]);
+ assertEq(decoded.feeDetails.length, 2);
+ assertEq(decoded.feeDetails[0].feeBps, 250);
+ assertEq(decoded.feeDetails[1].flatFee, 500e6);
+
+ string memory extensionJson = cyberCorp.getExtensionURI();
+ assertEq(
+ extensionJson,
+ complianceExtension.getExtensionURI(encoded)
+ );
+ assertTrue(
+ bytes(extensionJson).length > 0,
+ "compliance extension json should not be empty"
+ );
+ assertTrue(
+ _contains(extensionJson, '"erisaAllowed": "false"'),
+ "ERISA flag missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"maxOwnershipBps": "1500"'),
+ "max ownership missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"minNonZeroOwnershipBps": "5"'),
+ "min non-zero ownership missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"cfiusApprovalRequired": "true"'),
+ "CFIUS flag missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"Platform fee"'),
+ "fee details missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"No sanctioned persons"'),
+ "holder restrictions missing"
+ );
+ }
+
+ function test_SetFundExtensionDataWithFundWideTermsAndDocuments() public {
+ CyberCorpFundExtension fundExtension = new CyberCorpFundExtension();
+ fundExtension.initialize(address(auth));
+
+ string[] memory governingDocumentURIs = new string[](3);
+ governingDocumentURIs[0] = "ipfs://operating-agreement";
+ governingDocumentURIs[1] = "ipfs://subscription-agreement";
+ governingDocumentURIs[2] = "ipfs://ppm";
+
+ CyberCorpFundData memory fundData = CyberCorpFundData({
+ fundEntityType: "LP",
+ icaExceptionRelied: "3(c)(7)",
+ transferRestrictionHookAddress: address(0xF00D),
+ governingDocumentURIs: governingDocumentURIs,
+ metadataURI: "ipfs://fund-metadata"
+ });
+
+ bytes memory encoded = fundExtension.encodeExtensionData(fundData);
+
+ vm.startPrank(owner);
+ cyberCorp.setExtension(
+ address(fundExtension),
+ fundExtension.EXTENSION_TYPE()
+ );
+ cyberCorp.setExtensionData(encoded);
+ vm.stopPrank();
+
+ CyberCorpFundData memory decoded =
+ fundExtension.decodeExtensionData(cyberCorp.extensionData());
+
+ assertEq(cyberCorp.extension(), address(fundExtension));
+ assertEq(cyberCorp.extensionType(), fundExtension.EXTENSION_TYPE());
+ assertEq(decoded.fundEntityType, "LP");
+ assertEq(decoded.icaExceptionRelied, "3(c)(7)");
+ assertEq(decoded.transferRestrictionHookAddress, address(0xF00D));
+ assertEq(decoded.governingDocumentURIs.length, 3);
+ assertEq(decoded.governingDocumentURIs[2], "ipfs://ppm");
+ assertEq(decoded.metadataURI, "ipfs://fund-metadata");
+
+ string memory extensionJson = cyberCorp.getExtensionURI();
+ assertEq(extensionJson, fundExtension.getExtensionURI(encoded));
+ assertTrue(
+ bytes(extensionJson).length > 0,
+ "fund extension json should not be empty"
+ );
+ assertTrue(
+ _contains(extensionJson, '"fundEntityType": "LP"'),
+ "fund entity type missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"icaExceptionRelied": "3(c)(7)"'),
+ "ICA exception missing"
+ );
+ assertTrue(
+ _contains(extensionJson, "ipfs://operating-agreement"),
+ "governing document missing"
+ );
+ assertTrue(
+ _contains(extensionJson, '"metadataURI": "ipfs://fund-metadata"'),
+ "metadata URI missing"
+ );
+ }
+
+ function _contains(
+ string memory haystack,
+ string memory needle
+ ) internal pure returns (bool) {
+ bytes memory haystackBytes = bytes(haystack);
+ bytes memory needleBytes = bytes(needle);
+
+ if (needleBytes.length == 0) return true;
+ if (needleBytes.length > haystackBytes.length) return false;
+
+ for (uint256 i = 0; i <= haystackBytes.length - needleBytes.length; i++) {
+ bool matches = true;
+ for (uint256 j = 0; j < needleBytes.length; j++) {
+ if (haystackBytes[i + j] != needleBytes[j]) {
+ matches = false;
+ break;
+ }
+ }
+
+ if (matches) return true;
+ }
+
+ return false;
+ }
+}
diff --git a/test/CyberCorpTest.t.sol b/test/CyberCorpTest.t.sol
index 8d0b2784..7d72fbb5 100644
--- a/test/CyberCorpTest.t.sol
+++ b/test/CyberCorpTest.t.sol
@@ -53,6 +53,8 @@ import {BorgAuth} from "../src/libs/auth.sol";
import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
import {DealManagerFactory} from "../src/DealManagerFactory.sol";
import {IDealManager} from "../src/interfaces/IDealManager.sol";
+import {IDealManagerStorage} from "../src/interfaces/IDealManagerStorage.sol";
+import {ILexScrowStorage} from "../src/interfaces/ILexScrowStorage.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
@@ -65,6 +67,7 @@ import {CertificateImageBuilderContract} from "../src/CertificateImageBuilderCon
import "@openzeppelin/contracts/utils/Create2.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {DealManager} from "../src/DealManager.sol";
+import {DealManagerStorage} from "../src/storage/DealManagerStorage.sol";
import {RoundManager} from "../src/RoundManager.sol";
import {Escrow} from "../src/storage/LexScrowStorage.sol";
import {CyberCorp} from "../src/CyberCorp.sol";
@@ -74,7 +77,7 @@ import {CyberAgreementUtils} from "./libs/CyberAgreementUtils.sol";
import {SAFTEExtension, SAFTEData} from "../src/storage/extensions/SAFTEExtension.sol";
import {LeXcheX} from "../src/creds/lexchex.sol";
import {LeXcheXMinter} from "../src/creds/lexchexMinter.sol";
-import {LexScroWLite} from "../src/libs/LexScroWLite.sol";
+import {LexScrowStorage} from "../src/storage/LexScrowStorage.sol";
import {LexChexCondition} from "../src/libs/conditions/lexchexCondition.sol";
import {LeXcheXUtils} from "./libs/LeXcheXUtils.sol";
import {Accreditation} from "../src/creds/storage/lexchexStorage.sol";
@@ -1611,7 +1614,7 @@ contract CyberCorpForkTest is Test {
vm.stopPrank();
// Try to revoke after payment - should fail
- vm.expectRevert(DealManager.CounterPartyValueMismatch.selector);
+ vm.expectRevert(IDealManagerStorage.CounterPartyValueMismatch.selector);
IDealManager(dealManagerAddr).revokeDeal(id, testAddress, signature);
vm.stopPrank();
}
@@ -1917,8 +1920,9 @@ contract CyberCorpForkTest is Test {
block.timestamp + 1000000
);
- // Try to finalize without payment - should fail
- vm.expectRevert(LexScroWLite.DealNotPaid.selector);
+ // Try to finalize without payment - should fail. parties[1] is address(0) and never signs,
+ // so the all-parties-signed check fires before the unpaid-escrow check.
+ vm.expectRevert(LexScrowStorage.DealNotFullySigned.selector);
IDealManager(dealManagerAddr).finalizeDeal(id);
vm.stopPrank();
}
@@ -2179,8 +2183,9 @@ contract CyberCorpForkTest is Test {
""
);
- // Try to finalize again - should fail
- vm.expectRevert(LexScroWLite.DealNotPaid.selector);
+ // Try to finalize again - should fail. The deal is already finalized in the registry,
+ // so the already-finalized check fires before the unpaid-escrow check.
+ vm.expectRevert(LexScrowStorage.DealAlreadyFinalized.selector);
IDealManager(dealManagerAddr).finalizeDeal(id);
vm.stopPrank();
}
@@ -2321,7 +2326,7 @@ contract CyberCorpForkTest is Test {
);
// Try to void after finalization - should fail
- vm.expectRevert(DealManager.DealNotExpired.selector);
+ vm.expectRevert(IDealManagerStorage.DealNotExpired.selector);
IDealManager(dealManagerAddr).voidExpiredDeal(
id,
testAddress,
@@ -2584,7 +2589,7 @@ contract CyberCorpForkTest is Test {
);
// Try to sign expired contract - should fail
- vm.expectRevert(LexScroWLite.DealExpired.selector);
+ vm.expectRevert(LexScrowStorage.DealExpired.selector);
IDealManager(dealManagerAddr).signDealAndPay(
newPartyAddr,
id,
@@ -4139,8 +4144,8 @@ contract CyberCorpForkTest is Test {
string[] memory defaultLegend = new string[](1);
defaultLegend[0] = "Test Legend";
- DealManager.CyberCertData[] memory certData = new DealManager.CyberCertData[](1);
- certData[0] = DealManager.CyberCertData({
+ DealManagerStorage.CyberCertData[] memory certData = new DealManagerStorage.CyberCertData[](1);
+ certData[0] = DealManagerStorage.CyberCertData({
name: "Test Certificate",
symbol: "TEST",
uri: "ipfs://test-uri",
@@ -4301,8 +4306,8 @@ contract CyberCorpForkTest is Test {
string[] memory warrantLegend = new string[](1);
warrantLegend[0] = "Token Warrant Legend";
- DealManager.CyberCertData[] memory certData = new DealManager.CyberCertData[](2);
- certData[0] = DealManager.CyberCertData({
+ DealManagerStorage.CyberCertData[] memory certData = new DealManagerStorage.CyberCertData[](2);
+ certData[0] = DealManagerStorage.CyberCertData({
name: "SAFE Certificate",
symbol: "SAFE",
uri: "ipfs://safe-uri",
@@ -4311,7 +4316,7 @@ contract CyberCorpForkTest is Test {
extension: address(0),
defaultLegend: safeLegend
});
- certData[1] = DealManager.CyberCertData({
+ certData[1] = DealManagerStorage.CyberCertData({
name: "Token Warrant",
symbol: "TWARRANT",
uri: "ipfs://warrant-uri",
@@ -4497,8 +4502,8 @@ contract CyberCorpForkTest is Test {
string[] memory defaultLegend = new string[](1);
defaultLegend[0] = "Test Legend";
- DealManager.CyberCertData[] memory certData = new DealManager.CyberCertData[](1);
- certData[0] = DealManager.CyberCertData({
+ DealManagerStorage.CyberCertData[] memory certData = new DealManagerStorage.CyberCertData[](1);
+ certData[0] = DealManagerStorage.CyberCertData({
name: "Test Certificate",
symbol: "TEST",
uri: "ipfs://test-uri",
@@ -4880,7 +4885,7 @@ contract CyberCorpForkTest is Test {
);
// This should fail because the counterparty has an invalid (voided) LexChex token
- vm.expectRevert(DealManager.AgreementConditionsNotMet.selector); // Expect revert due to condition not being met
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector); // Expect revert due to condition not being met
dealManager.signAndFinalizeDeal(
newPartyAddr,
contractId,
@@ -5024,7 +5029,7 @@ contract CyberCorpForkTest is Test {
);
// This should fail because the counterparty has no LexChex token
- vm.expectRevert(DealManager.AgreementConditionsNotMet.selector); // Expect revert due to condition not being met
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector); // Expect revert due to condition not being met
dealManager.signAndFinalizeDeal(
newPartyAddr,
contractId,
diff --git a/test/CyberScripUpgradeTest.t.sol b/test/CyberScripUpgradeTest.t.sol
index 3c254b9c..8c3c0cba 100644
--- a/test/CyberScripUpgradeTest.t.sol
+++ b/test/CyberScripUpgradeTest.t.sol
@@ -19,6 +19,7 @@ import {IssuanceManager} from "../src/IssuanceManager.sol";
import {CyberCorp} from "../src/CyberCorp.sol";
import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
import {CyberScrip} from "../src/CyberScrip.sol";
+import {CertificateUriBuilder} from "../src/CertificateUriBuilder.sol";
import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
import {BorgAuth} from "../src/libs/auth.sol";
import {Round, RoundLib} from "../src/libs/RoundLib.sol";
@@ -973,6 +974,7 @@ contract CyberScripUpgradeForkTest is Test {
address newRoundManagerImpl = address(new RoundManager());
address newCertPrinterImpl = address(new CyberCertPrinter());
address newScripImpl = address(new CyberScrip());
+ address newUriBuilderImpl = address(new CertificateUriBuilder());
vm.startPrank(METALEX_SAFE);
corpSingleFactory.setRefImplementation(newCyberCorpImpl);
@@ -981,6 +983,7 @@ contract CyberScripUpgradeForkTest is Test {
rmFactory.setRefImplementation(newRoundManagerImpl);
imFactory.setCyberCertPrinterRefImplementation(newCertPrinterImpl);
imFactory.setCyberScripRefImplementation(newScripImpl);
+ IUUPS(deployment.uriBuilder).upgradeToAndCall(newUriBuilderImpl, "");
vm.stopPrank();
assertEq(
diff --git a/test/DealManagerFactoryTest.t.sol b/test/DealManagerFactoryTest.t.sol
index 50313047..a5929870 100644
--- a/test/DealManagerFactoryTest.t.sol
+++ b/test/DealManagerFactoryTest.t.sol
@@ -206,4 +206,50 @@ contract DealManagerFactoryTest is Test {
vm.expectRevert(DealManagerFactory.InvalidFeeRatio.selector);
dmFactory.setDefaultFeeRatio(DealManagerFactoryStorage.BASIS_POINTS + 1);
}
+
+ function test_SetIntegrator() public {
+ address integrator = address(0x123);
+ assertFalse(dmFactory.isIntegratorWhitelisted(integrator), "not whitelisted before set");
+ assertEq(dmFactory.getIntegratorFeeShare(integrator), 0, "no fee share before set");
+
+ vm.expectEmit(true, true, true, true);
+ emit DealManagerFactory.IntegratorSet(integrator, true, 3000);
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 3000);
+
+ assertTrue(dmFactory.isIntegratorWhitelisted(integrator), "whitelisted after set");
+ assertEq(dmFactory.getIntegratorFeeShare(integrator), 3000, "fee share set after set");
+ }
+
+ function test_SetIntegrator_Dewhitelist() public {
+ address integrator = address(0x123);
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 3000);
+
+ vm.expectEmit(true, true, true, true);
+ emit DealManagerFactory.IntegratorSet(integrator, false, 0);
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, false, 0);
+
+ assertFalse(dmFactory.isIntegratorWhitelisted(integrator), "not whitelisted after de-whitelist");
+ assertEq(dmFactory.getIntegratorFeeShare(integrator), 0, "fee share cleared after de-whitelist");
+ }
+
+ function test_RevertIf_SetIntegratorNonOwner() public {
+ vm.prank(companyOwner);
+ vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, companyOwner));
+ dmFactory.setIntegrator(address(0x123), true, 3000);
+ }
+
+ function test_RevertIf_SetIntegratorZeroAddress() public {
+ vm.prank(owner);
+ vm.expectRevert(DealManagerFactory.ZeroAddress.selector);
+ dmFactory.setIntegrator(address(0), true, 3000);
+ }
+
+ function test_RevertIf_SetIntegratorFeeShareTooHigh() public {
+ vm.prank(owner);
+ vm.expectRevert(DealManagerFactory.InvalidFeeRatio.selector);
+ dmFactory.setIntegrator(address(0x123), true, DealManagerFactoryStorage.BASIS_POINTS + 1);
+ }
}
diff --git a/test/DealManagerSecondaryTradeExemptionPathwayTest.t.sol b/test/DealManagerSecondaryTradeExemptionPathwayTest.t.sol
new file mode 100644
index 00000000..f97853de
--- /dev/null
+++ b/test/DealManagerSecondaryTradeExemptionPathwayTest.t.sol
@@ -0,0 +1,721 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+pragma solidity 0.8.28;
+
+import {ERC1967Proxy} from "../dependencies/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {ERC20} from "../dependencies/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
+import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
+import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
+import {SecurityClass, SecuritySeries} from "../src/CyberCorpConstants.sol";
+import {CyberScrip} from "../src/CyberScrip.sol";
+import {DealManager} from "../src/DealManager.sol";
+import {DealManagerFactory} from "../src/DealManagerFactory.sol";
+import {IssuanceManager} from "../src/IssuanceManager.sol";
+import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+import {CertificateDetails, ICyberCertPrinter} from "../src/interfaces/ICyberCertPrinter.sol";
+import {IDealManager} from "../src/interfaces/IDealManager.sol";
+import {IERC5484} from "../src/interfaces/IERC5484.sol";
+import {BorgAuth} from "../src/libs/auth.sol";
+import {LeXcheXBadge} from "../src/creds/lexchexBadge.sol";
+import {CategoryKind, Credential, CredentialCategory} from "../src/creds/storage/lexchexBadgeStorage.sol";
+import {FundInterestData} from "../src/storage/extensions/FundInterestExtension.sol";
+import {
+ AcceptOfferParams,
+ ExemptionPathway,
+ HostingMode,
+ ISecondaryTradeStorage,
+ Offer,
+ OfferSide,
+ PostOfferParams,
+ SecondaryEscrow,
+ SecondaryEscrowStatus
+} from "../src/storage/SecondaryTradeStorage.sol";
+// Real secondary-trading conditions under test.
+import {KYCAMLCondition} from "../src/libs/conditions/secondary/KYCAMLCondition.sol";
+import {TaxInfoCondition} from "../src/libs/conditions/secondary/TaxInfoCondition.sol";
+import {HolderCapCondition} from "../src/libs/conditions/secondary/HolderCapCondition.sol";
+import {ERISACondition} from "../src/libs/conditions/secondary/ERISACondition.sol";
+import {USStateOfResidenceCondition} from "../src/libs/conditions/secondary/USStateOfResidenceCondition.sol";
+import {LegionSoulboundCondition} from "../src/libs/conditions/secondary/LegionSoulboundCondition.sol";
+import {HoldingPeriodCondition} from "../src/libs/conditions/secondary/HoldingPeriodCondition.sol";
+import {LexChexBadgeKindCondition} from "../src/libs/conditions/secondary/LexChexBadgeKindCondition.sol";
+import {RegSDistributionComplianceCondition} from "../src/libs/conditions/secondary/RegSDistributionComplianceCondition.sol";
+import {Rule144DisclosureCondition} from "../src/libs/conditions/secondary/Rule144DisclosureCondition.sol";
+import {Section4a7DisclosureCondition} from "../src/libs/conditions/secondary/Section4a7DisclosureCondition.sol";
+import {LegalOpinionCondition} from "../src/libs/conditions/secondary/LegalOpinionCondition.sol";
+import {AgreementSignedCondition} from "../src/libs/conditions/secondary/AgreementSignedCondition.sol";
+import {GlobalKillCondition} from "../src/libs/conditions/secondary/GlobalKillCondition.sol";
+import {TimeSettlementPeriodCondition} from "../src/libs/conditions/secondary/TimeSettlementPeriodCondition.sol";
+import {CyberAgreementUtils} from "./libs/CyberAgreementUtils.sol";
+import {MockUriBuilderForIM} from "./IssuanceManagerTest.t.sol";
+import {Test} from "forge-std/Test.sol";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Mocks
+// ─────────────────────────────────────────────────────────────────────────────
+
+contract SecERC20Mock is ERC20 {
+ constructor() ERC20("Payment Token", "PAY") {}
+
+ function mint(address to, uint256 amount) public {
+ _mint(to, amount);
+ }
+}
+
+// cyberCORP fixture for the real IssuanceManager/DealManager that ALSO exposes AUTH(), which the
+// per-SPV condition setters (RegS.setRegSConfig, USState.setStateBlocked) resolve via
+// IBorgAuthProvider(spv).AUTH(). offer.spvAddress == this corp.
+contract MockCorpWithAuth {
+ address public AUTH;
+
+ constructor(address auth_) {
+ AUTH = auth_;
+ }
+
+ function cyberCORPName() external pure returns (string memory) { return "TestCorp"; }
+ function cyberCORPType() external pure returns (string memory) { return "C-Corp"; }
+ function cyberCORPJurisdiction() external pure returns (string memory) { return "DE"; }
+ function cyberCORPContactDetails() external pure returns (string memory) { return "test@corp.test"; }
+ function dealManager() external pure returns (address) { return address(0); }
+ function roundManager() external pure returns (address) { return address(0); }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test contract
+// ─────────────────────────────────────────────────────────────────────────────
+
+contract DealManagerSecondaryTradeExemptionPathwayTest is Test {
+ bytes32 constant corpSalt = keccak256("DealManagerSecondaryTradeExemptionPathwayTest.corp");
+ bytes32 constant imSalt = keccak256("DealManagerSecondaryTradeExemptionPathwayTest.im");
+
+ // Single template carrying TWO party fields, so the buyer's ERISA attestation and §4(a)(7)
+ // acknowledgment-of-receipt can be recorded as signer values on the settlement agreement
+ // (ERISACondition / Section4a7DisclosureCondition read registry.getSignerValues).
+ bytes32 public constant TEMPLATE_ID = bytes32(0);
+ string public constant TEMPLATE_URI = "ipfs://exemption-template";
+ string public constant ERISA_ATTESTATION = "ERISA:no-plan-assets";
+ string public constant SECTION4A7_ACK = "4a7:information-package-received";
+ string public constant DISCLOSURE_URI = "ipfs://disclosure-package";
+ // Freshness policy for both disclosure conditions (Rule 144(c)(2) practice: 16 months)
+ uint256 public constant DISCLOSURE_MAX_AGE = 480 days;
+
+ // Category ids for the credential layer.
+ bytes32 constant CAT_KYC = keccak256("cat.kyc");
+ bytes32 constant CAT_ACCREDITED = keccak256("cat.accredited");
+ bytes32 constant CAT_QIB = keccak256("cat.qib");
+ bytes32 constant CAT_NONUS = keccak256("cat.nonus");
+ bytes32 constant CAT_LEGION = keccak256("cat.legion");
+
+ bytes2 constant CA = "CA";
+
+ uint256 public constant UNITS = 100;
+ uint256 public constant CONSIDERATION = 10 ether;
+ uint64 public constant HOLD = 365 days;
+
+ address public owner;
+ uint256 public ownerKey;
+ address public seller;
+ uint256 public sellerKey;
+ address public keeper;
+
+ SecERC20Mock public paymentToken;
+ BorgAuth public auth;
+ MockCorpWithAuth public corp;
+ IssuanceManager public im;
+ ICyberCertPrinter public certPrinter;
+ CyberAgreementRegistry public registry;
+ DealManagerFactory public dmFactory;
+ DealManager public dm;
+ LeXcheXBadge public badge;
+
+ // Real conditions.
+ KYCAMLCondition public kyc;
+ TaxInfoCondition public taxInfo;
+ HolderCapCondition public holderCap;
+ ERISACondition public erisa;
+ USStateOfResidenceCondition public usState;
+ LegionSoulboundCondition public legion;
+ HoldingPeriodCondition public holdingPeriod;
+ LexChexBadgeKindCondition public accredited;
+ LexChexBadgeKindCondition public qib;
+ LexChexBadgeKindCondition public nonUsPerson;
+ RegSDistributionComplianceCondition public regS;
+ Rule144DisclosureCondition public rule144Disclosure;
+ Section4a7DisclosureCondition public section4a7Disclosure;
+ LegalOpinionCondition public legalOpinion;
+ AgreementSignedCondition public agreementSigned;
+ GlobalKillCondition public globalKill;
+ TimeSettlementPeriodCondition public timeSettlement;
+
+ address public metalexKillAdmin;
+ address public legionKillAdmin;
+
+ uint256 public sellerTokenId;
+
+ function setUp() public {
+ // Warp forward so the seller cert's acquisitionDate can sit comfortably in the past.
+ vm.warp(500 days);
+
+ (owner, ownerKey) = makeAddrAndKey("owner");
+ (seller, sellerKey) = makeAddrAndKey("seller");
+ keeper = makeAddr("keeper");
+
+ paymentToken = new SecERC20Mock();
+ auth = new BorgAuth(owner);
+ corp = new MockCorpWithAuth(address(auth));
+
+ // Real IssuanceManager + CyberCertPrinter via the factory beacon stack.
+ IssuanceManagerFactory imFactory = IssuanceManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new IssuanceManagerFactory()),
+ abi.encodeWithSelector(
+ IssuanceManagerFactory.initialize.selector,
+ address(auth),
+ new IssuanceManager(),
+ new CyberCertPrinter(),
+ new CyberScrip()
+ )
+ )
+ )
+ );
+ im = IssuanceManager(imFactory.deployIssuanceManager(imSalt));
+ im.initialize(address(auth), address(corp), address(new MockUriBuilderForIM()), address(imFactory));
+
+ // Real CyberAgreementRegistry with a one-party-field template (for the ERISA attestation).
+ registry = CyberAgreementRegistry(
+ address(
+ new ERC1967Proxy(
+ address(new CyberAgreementRegistry()),
+ abi.encodeWithSelector(CyberAgreementRegistry.initialize.selector, address(auth))
+ )
+ )
+ );
+ string[] memory partyFields = _partyFields();
+ vm.prank(owner);
+ registry.createTemplate(TEMPLATE_ID, "Secondary", TEMPLATE_URI, new string[](0), partyFields);
+
+ dmFactory = DealManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new DealManagerFactory()),
+ abi.encodeWithSelector(
+ DealManagerFactory.initialize.selector, address(auth), address(new DealManager())
+ )
+ )
+ )
+ );
+ dm = DealManager(dmFactory.deployDealManager(corpSalt));
+ dm.initialize(address(auth), address(corp), address(registry), address(im), address(dmFactory));
+ vm.prank(owner);
+ auth.updateRole(address(dm), 99);
+
+ _deployBadgeAndCategories();
+ _deployConditions();
+ _wireConditions();
+
+ // Reg S per-SPV config: Category 3, one-year distribution compliance period.
+ vm.prank(owner);
+ regS.setRegSConfig(address(corp), 3, HOLD);
+
+ // Disclosure packages on record for the SPV (Rule 144(c)(2) and §4(a)(7)(d)(3)), fresh as of now.
+ vm.startPrank(owner);
+ rule144Disclosure.setDisclosurePackage(address(corp), DISCLOSURE_URI, uint64(block.timestamp));
+ section4a7Disclosure.setDisclosurePackage(address(corp), DISCLOSURE_URI, uint64(block.timestamp));
+ vm.stopPrank();
+
+ // Seller: KYC badge + a Ledger Entry Token whose acquisitionDate is > HOLD in the past.
+ _mintCred(seller, CAT_KYC, "US", CA);
+ vm.startPrank(owner);
+ certPrinter = ICyberCertPrinter(
+ im.createCertPrinter(
+ new string[](0),
+ "Secondary Cert",
+ "SCERT",
+ "uri://cert",
+ SecurityClass.CommonStock,
+ SecuritySeries.SeriesA,
+ address(0)
+ )
+ );
+ sellerTokenId = im.createCertAndAssign(address(certPrinter), seller, _sellerCertDetails());
+ vm.stopPrank();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Happy-path tests — one full trade per exemption pathway
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_Rule144_HappyPath() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.rule144");
+ _commonBuyerSetup(buyer, "US", CA);
+ // No buyer-side pathway credential: Rule 144 gates on the seller's elapsed holding period.
+ _runHappyPath(ExemptionPathway.RULE_144, buyer, buyerKey, uint256(keccak256("rule144")));
+ }
+
+ function test_Section4a7_HappyPath() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.4a7");
+ _commonBuyerSetup(buyer, "US", CA);
+ _mintCred(buyer, CAT_ACCREDITED, "US", bytes2(0));
+ _runHappyPath(ExemptionPathway.SECTION_4A7, buyer, buyerKey, uint256(keccak256("4a7")));
+ }
+
+ function test_Section4a1Half_HappyPath() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.4a1half");
+ _commonBuyerSetup(buyer, "US", CA);
+ // Sophisticated-but-not-accredited: KYC + Legion only; the pathway gate is the GP's recorded
+ // compliance sign-off (LegalOpinionCondition, GP_SIGNOFF-or-opinion default mechanism).
+ // Sign-off is per-deal: silent at posting, then the GP pre-approves the offer (covering every
+ // settlement of it) before any acceptance.
+ bytes32 offerId = _postSellOffer(ExemptionPathway.SECTION_4A1HALF, uint256(keccak256("4a1half")));
+ vm.prank(owner);
+ legalOpinion.recordGPSignOff(address(dm), offerId);
+ _acceptAndFinalize(offerId, buyer, buyerKey);
+ }
+
+ function test_Rule144A_HappyPath() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.144a");
+ _commonBuyerSetup(buyer, "US", CA);
+ _mintCred(buyer, CAT_QIB, "US", bytes2(0));
+ _runHappyPath(ExemptionPathway.RULE_144A, buyer, buyerKey, uint256(keccak256("144a")));
+ }
+
+ function test_RegulationS_HappyPath() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.regs");
+ // Non-U.S. person: jurisdiction KY, no usState (badge forbids usState for non-US holders).
+ _commonBuyerSetup(buyer, "KY", bytes2(0));
+ _mintCred(buyer, CAT_NONUS, "KY", bytes2(0));
+ _runHappyPath(ExemptionPathway.REGULATION_S, buyer, buyerKey, uint256(keccak256("regs")));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Closing-condition behavior (the two platform-wide closing conditions)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @notice A raised kill flag suspends finalization of an in-flight settlement; the two-call
+ /// lower (one admin proposes, the other confirms) restores it.
+ function test_GlobalKill_BlocksFinalize_UntilLowered() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.kill");
+ _commonBuyerSetup(buyer, "US", CA);
+
+ bytes32 offerId = _postSellOffer(ExemptionPathway.RULE_144, uint256(keccak256("kill")));
+ bytes32 settlementId = _acceptSellOffer(offerId, buyer, buyerKey);
+ vm.warp(block.timestamp + timeSettlement.DEFAULT_DELAY() + 1);
+
+ // Either admin can raise unilaterally — after acceptance, mid-deal.
+ vm.prank(legionKillAdmin);
+ globalKill.raiseKill();
+
+ vm.expectRevert(
+ abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, address(globalKill))
+ );
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ // Lowering takes both admins: the proposer alone cannot confirm.
+ vm.prank(legionKillAdmin);
+ globalKill.proposeLower();
+ vm.expectRevert(GlobalKillCondition.ProposerCannotConfirm.selector);
+ vm.prank(legionKillAdmin);
+ globalKill.confirmLower();
+ vm.prank(metalexKillAdmin);
+ globalKill.confirmLower();
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "escrow FINALIZED after kill lowered"
+ );
+ }
+
+ /// @notice Finalization before the 24h minimum settlement period fails; after the window it passes.
+ function test_TimeSettlement_BlocksEarlyFinalize() public {
+ (address buyer, uint256 buyerKey) = makeAddrAndKey("buyer.timing");
+ _commonBuyerSetup(buyer, "US", CA);
+
+ bytes32 offerId = _postSellOffer(ExemptionPathway.RULE_144, uint256(keccak256("timing")));
+ bytes32 settlementId = _acceptSellOffer(offerId, buyer, buyerKey);
+
+ assertEq(
+ timeSettlement.finalizableAt(IDealManager(address(dm)), settlementId),
+ block.timestamp + timeSettlement.DEFAULT_DELAY(),
+ "finalizableAt = acceptance + 24h"
+ );
+
+ vm.expectRevert(
+ abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, address(timeSettlement))
+ );
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.warp(block.timestamp + timeSettlement.DEFAULT_DELAY() + 1);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "escrow FINALIZED after settlement period"
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Lifecycle helper
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _runHappyPath(ExemptionPathway pathway, address buyer, uint256 buyerKey, uint256 salt) internal {
+ bytes32 offerId = _postSellOffer(pathway, salt);
+ _acceptAndFinalize(offerId, buyer, buyerKey);
+ }
+
+ function _acceptAndFinalize(bytes32 offerId, address buyer, uint256 buyerKey)
+ internal
+ returns (bytes32 settlementId)
+ {
+ uint256 sellerBalanceBefore = paymentToken.balanceOf(seller);
+
+ settlementId = _acceptSellOffer(offerId, buyer, buyerKey);
+
+ // TimeSettlementPeriodCondition (closing): the 24h minimum settlement period must elapse
+ // between acceptance and finalization before the keeper can finalize.
+ vm.warp(block.timestamp + timeSettlement.DEFAULT_DELAY() + 1);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ SecondaryEscrow memory se = dm.getSecondaryEscrow(settlementId);
+ assertEq(uint8(se.status), uint8(SecondaryEscrowStatus.FINALIZED), "escrow FINALIZED");
+ assertGt(paymentToken.balanceOf(seller) - sellerBalanceBefore, 0, "seller received payment");
+ assertGt(certPrinter.balanceOfLegalOwner(buyer), 0, "buyer holds a new Ledger Entry Token");
+ assertEq(_consumed(sellerTokenId), UNITS, "seller units fully consumed");
+ }
+
+ function _postSellOffer(ExemptionPathway pathway, uint256 salt) internal returns (bytes32 offerId) {
+ PostOfferParams memory p = PostOfferParams({
+ side: OfferSide.SELL,
+ certPrinter: address(certPrinter),
+ tokenId: sellerTokenId,
+ units: UNITS,
+ paymentToken: address(paymentToken),
+ consideration: CONSIDERATION,
+ exemptionPathway: pathway,
+ validUntil: block.timestamp + 1 days,
+ counterpartyRestrictions: "",
+ additionalTerms: "",
+ integrator: address(0),
+ templateId: TEMPLATE_ID,
+ salt: salt,
+ globalValues: new string[](0),
+ offerorPartyValues: _two("", ""),
+ offerorAgreementSig: "",
+ openEndorsementSig: "sellerEndorsement",
+ buyerName: "",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0)
+ });
+ vm.prank(seller);
+ offerId = dm.postOffer(p);
+ }
+
+ function _acceptSellOffer(bytes32 offerId, address buyer, uint256 buyerKey)
+ internal
+ returns (bytes32 settlementId)
+ {
+ // The buyer records both attestations as signer values; each condition scans for its own marker,
+ // so carrying the §4(a)(7) ack on non-4a7 pathways is harmless.
+ string[] memory pv = _two(ERISA_ATTESTATION, SECTION4A7_ACK);
+ AcceptOfferParams memory a = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: "Bob",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: pv,
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey, pv),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ settlementId = dm.acceptOffer(a);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Setup helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _deployBadgeAndCategories() internal {
+ badge = LeXcheXBadge(
+ address(
+ new ERC1967Proxy(
+ address(new LeXcheXBadge()),
+ abi.encodeCall(LeXcheXBadge.initialize, (address(auth)))
+ )
+ )
+ );
+ _createCategory(CAT_KYC, CategoryKind.KYC_AML);
+ _createCategory(CAT_ACCREDITED, CategoryKind.ACCREDITED_INVESTOR);
+ _createCategory(CAT_QIB, CategoryKind.QIB);
+ _createCategory(CAT_NONUS, CategoryKind.NON_US_PERSON);
+ _createCategory(CAT_LEGION, CategoryKind.CUSTOM);
+ }
+
+ function _deployConditions() internal {
+ kyc = KYCAMLCondition(
+ _proxy(address(new KYCAMLCondition()), abi.encodeCall(KYCAMLCondition.initialize, (address(auth), address(badge))))
+ );
+ taxInfo = TaxInfoCondition(
+ _proxy(address(new TaxInfoCondition()), abi.encodeCall(TaxInfoCondition.initialize, (address(auth))))
+ );
+ holderCap = HolderCapCondition(
+ _proxy(
+ address(new HolderCapCondition()),
+ abi.encodeCall(
+ HolderCapCondition.initialize,
+ (address(auth), address(badge), HolderCapCondition.IcaException.SECTION_3C1, uint256(100), false, false)
+ )
+ )
+ );
+ erisa = ERISACondition(
+ _proxy(
+ address(new ERISACondition()),
+ abi.encodeCall(ERISACondition.initialize, (address(auth), address(registry), ERISA_ATTESTATION))
+ )
+ );
+ usState = USStateOfResidenceCondition(
+ _proxy(
+ address(new USStateOfResidenceCondition()),
+ abi.encodeCall(USStateOfResidenceCondition.initialize, (address(auth), address(badge)))
+ )
+ );
+ legion = LegionSoulboundCondition(
+ _proxy(
+ address(new LegionSoulboundCondition()),
+ abi.encodeCall(LegionSoulboundCondition.initialize, (address(auth), address(badge), CAT_LEGION, false))
+ )
+ );
+ holdingPeriod = HoldingPeriodCondition(
+ _proxy(
+ address(new HoldingPeriodCondition()),
+ abi.encodeCall(HoldingPeriodCondition.initialize, (address(auth), uint256(HOLD)))
+ )
+ );
+ accredited = _deployKindCondition(CategoryKind.ACCREDITED_INVESTOR);
+ qib = _deployKindCondition(CategoryKind.QIB);
+ nonUsPerson = _deployKindCondition(CategoryKind.NON_US_PERSON);
+ regS = RegSDistributionComplianceCondition(
+ _proxy(
+ address(new RegSDistributionComplianceCondition()),
+ abi.encodeCall(RegSDistributionComplianceCondition.initialize, (address(auth)))
+ )
+ );
+ rule144Disclosure = Rule144DisclosureCondition(
+ _proxy(
+ address(new Rule144DisclosureCondition()),
+ abi.encodeCall(Rule144DisclosureCondition.initialize, (address(auth), DISCLOSURE_MAX_AGE))
+ )
+ );
+ section4a7Disclosure = Section4a7DisclosureCondition(
+ _proxy(
+ address(new Section4a7DisclosureCondition()),
+ abi.encodeCall(
+ Section4a7DisclosureCondition.initialize,
+ (address(auth), address(registry), SECTION4A7_ACK, DISCLOSURE_MAX_AGE)
+ )
+ )
+ );
+ legalOpinion = LegalOpinionCondition(
+ _proxy(
+ address(new LegalOpinionCondition()),
+ abi.encodeCall(LegalOpinionCondition.initialize, (address(auth)))
+ )
+ );
+ agreementSigned = AgreementSignedCondition(
+ _proxy(
+ address(new AgreementSignedCondition()),
+ abi.encodeCall(AgreementSignedCondition.initialize, (address(auth), address(registry)))
+ )
+ );
+ // Closing conditions are plain (non-proxied) singletons.
+ metalexKillAdmin = makeAddr("metalexKillAdmin");
+ legionKillAdmin = makeAddr("legionKillAdmin");
+ globalKill = new GlobalKillCondition(metalexKillAdmin, legionKillAdmin);
+ timeSettlement = new TimeSettlementPeriodCondition();
+ }
+
+ function _deployKindCondition(CategoryKind kind) internal returns (LexChexBadgeKindCondition) {
+ return LexChexBadgeKindCondition(
+ _proxy(
+ address(new LexChexBadgeKindCondition()),
+ abi.encodeCall(LexChexBadgeKindCondition.initialize, (address(auth), address(badge), kind, "", false))
+ )
+ );
+ }
+
+ function _wireConditions() internal {
+ // SPV-layer (every pathway).
+ _addSpv(address(kyc));
+ _addSpv(address(taxInfo));
+ _addSpv(address(holderCap));
+ _addSpv(address(erisa));
+ _addSpv(address(usState));
+ _addSpv(address(legion));
+ _addSpv(address(agreementSigned));
+
+ // Pathway-layer.
+ _addPathway(ExemptionPathway.RULE_144, address(holdingPeriod));
+ _addPathway(ExemptionPathway.RULE_144, address(rule144Disclosure));
+ _addPathway(ExemptionPathway.SECTION_4A7, address(accredited));
+ _addPathway(ExemptionPathway.SECTION_4A7, address(section4a7Disclosure));
+ _addPathway(ExemptionPathway.SECTION_4A1HALF, address(legalOpinion));
+ _addPathway(ExemptionPathway.RULE_144A, address(qib));
+ _addPathway(ExemptionPathway.REGULATION_S, address(nonUsPerson));
+ _addPathway(ExemptionPathway.REGULATION_S, address(regS));
+
+ // Closing set (all pathways).
+ _addClosing(address(globalKill));
+ _addClosing(address(timeSettlement));
+ }
+
+ function _addClosing(address condition) internal {
+ vm.prank(owner);
+ dm.addClosingCondition(condition);
+ }
+
+ function _commonBuyerSetup(address buyer, string memory jurisdiction, bytes2 state) internal {
+ _mintCred(buyer, CAT_KYC, jurisdiction, state);
+ _mintCred(buyer, CAT_LEGION, jurisdiction, state);
+ vm.prank(owner);
+ taxInfo.setTaxForm(buyer, TaxInfoCondition.TaxFormType.W9, keccak256("form"));
+
+ paymentToken.mint(buyer, CONSIDERATION * 10);
+ vm.prank(buyer);
+ paymentToken.approve(address(dm), type(uint256).max);
+ }
+
+ function _createCategory(bytes32 id, CategoryKind kind) internal {
+ CredentialCategory memory c = CredentialCategory({
+ name: "cat",
+ description: "",
+ kind: kind,
+ defaultValidityDuration: 3650 days,
+ requiresUsState: false,
+ requiresBeneficialOwnerCount: false,
+ requiresEvidenceHash: false,
+ burnAuth: IERC5484.BurnAuth.OwnerOnly,
+ scope: address(0),
+ active: true,
+ exists: true
+ });
+ vm.prank(owner);
+ badge.createCategory(id, c);
+ }
+
+ function _mintCred(address to, bytes32 categoryId, string memory jurisdiction, bytes2 state) internal {
+ Credential memory cred = Credential({
+ categoryId: categoryId,
+ investorName: "Inv",
+ investorType: "Individual",
+ investorJurisdiction: jurisdiction,
+ usState: state,
+ beneficialOwnerCount: 0,
+ issuanceDate: 0,
+ expiryDate: 0,
+ voided: "",
+ agreementId: bytes32(0),
+ evidenceHash: bytes32(0),
+ extensionData: ""
+ });
+ vm.prank(owner);
+ badge.mint(to, categoryId, cred);
+ }
+
+ function _sellerCertDetails() internal view returns (CertificateDetails memory) {
+ return CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 1000,
+ issuerUSDValuationAtTimeOfInvestment: 10000,
+ unitsRepresented: UNITS,
+ legalDetails: "",
+ extensionData: abi.encode(
+ FundInterestData({
+ acquisitionDate: uint64(block.timestamp - 400 days),
+ tackedFromAcquisitionDate: 0,
+ customProvisions: ""
+ })
+ )
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Small utilities
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _proxy(address impl, bytes memory initData) internal returns (address) {
+ return address(new ERC1967Proxy(impl, initData));
+ }
+
+ function _addSpv(address condition) internal {
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(condition);
+ }
+
+ function _addPathway(ExemptionPathway pathway, address condition) internal {
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(pathway, condition);
+ }
+
+ function _partyFields() internal pure returns (string[] memory f) {
+ f = new string[](2);
+ f[0] = "erisaAttestation";
+ f[1] = "section4a7Ack";
+ }
+
+ function _two(string memory v0, string memory v1) internal pure returns (string[] memory a) {
+ a = new string[](2);
+ a[0] = v0;
+ a[1] = v1;
+ }
+
+ /// @dev Cumulative units consumed from the seller cert: a full sale voids it, a partial decrements.
+ function _consumed(uint256 tokenId) internal view returns (uint256) {
+ if (certPrinter.isVoided(tokenId)) return UNITS;
+ return UNITS - certPrinter.getCertificateDetails(tokenId).unitsRepresented;
+ }
+
+ /// @dev Recomputes the next settlement agreement id for an offer and returns the acceptor's EIP-712
+ /// signature over it. partyValues must match what acceptOffer submits (the ERISA attestation).
+ function _acceptorSig(bytes32 offerId, address acceptor, uint256 key, string[] memory partyValues)
+ internal
+ view
+ returns (bytes memory)
+ {
+ Offer memory o = dm.getOffer(offerId);
+ bytes32 settlementSalt = keccak256(abi.encodePacked(o.salt, o.settlementAgreementIds.length));
+ address[] memory parties = new address[](2);
+ parties[0] = o.offeror;
+ parties[1] = acceptor;
+ bytes32 settlementId = keccak256(abi.encode(o.templateId, uint256(settlementSalt), o.globalValues, parties));
+ return _agreementSig(settlementId, partyValues, key);
+ }
+
+ /// @dev EIP-712 agreement signature over a settlement id, using the template's party fields.
+ function _agreementSig(bytes32 settlementId, string[] memory partyValues, uint256 key)
+ internal
+ view
+ returns (bytes memory)
+ {
+ return CyberAgreementUtils.signAgreementTypedData(
+ vm,
+ registry.DOMAIN_SEPARATOR(),
+ registry.SIGNATUREDATA_TYPEHASH(),
+ settlementId,
+ TEMPLATE_URI,
+ new string[](0), // globalFields (template has none)
+ _partyFields(), // partyFields (must match the template)
+ new string[](0), // globalValues
+ partyValues,
+ key
+ );
+ }
+}
diff --git a/test/DealManagerSecondaryTradeIndexerTest.t.sol b/test/DealManagerSecondaryTradeIndexerTest.t.sol
new file mode 100644
index 00000000..f938e62e
--- /dev/null
+++ b/test/DealManagerSecondaryTradeIndexerTest.t.sol
@@ -0,0 +1,1279 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+pragma solidity 0.8.28;
+
+import {ERC1967Proxy} from "../dependencies/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
+import {DealManager} from "../src/DealManager.sol";
+import {DealManagerFactory} from "../src/DealManagerFactory.sol";
+import {IIssuanceManager} from "../src/interfaces/IIssuanceManager.sol";
+import {ISecondaryTradeStorage} from "../src/interfaces/ISecondaryTradeStorage.sol";
+import {BorgAuth} from "../src/libs/auth.sol";
+import {
+ AcceptOfferParams,
+ ExemptionPathway,
+ HostingMode,
+ Offer,
+ OfferSide,
+ OfferStatus,
+ PostOfferParams,
+ SecondaryEscrow,
+ SecondaryEscrowStatus
+} from "../src/storage/SecondaryTradeStorage.sol";
+import {CyberAgreementUtils} from "./libs/CyberAgreementUtils.sol";
+import {Test, console2} from "forge-std/Test.sol";
+import {Vm} from "forge-std/Vm.sol";
+// Real contract stack for the IssuanceManager + CyberCertPrinter side.
+import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
+import {SecurityClass, SecuritySeries} from "../src/CyberCorpConstants.sol";
+import {CyberScrip} from "../src/CyberScrip.sol";
+import {IssuanceManager} from "../src/IssuanceManager.sol";
+import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+import {CertificateDetails, ICyberCertPrinter} from "../src/interfaces/ICyberCertPrinter.sol";
+// Reuse the payment-token mock and the minimal CyberCorp / uriBuilder fixtures from sibling test files.
+import {SecERC20Mock} from "./DealManagerSecondaryTradeTest.t.sol";
+import {
+ MockCyberCorpForIM,
+ MockUriBuilderForIM
+} from "./IssuanceManagerTest.t.sol";
+
+/// @title DealManagerSecondaryTradeIndexerTest
+/// @notice Simulates an off-chain indexer (e.g. powering the Legion UI) and proves the secondary-trade
+/// events emit enough information to reconstruct trading status from logs alone.
+/// @dev Each scenario records the logs of a real trade lifecycle, replays them through an in-memory indexer
+/// that only ever reads event data (never contract storage), then asserts the reconstructed Offer and
+/// settlement state matches the on-chain truth from getOffer/getSecondaryEscrow.
+contract DealManagerSecondaryTradeIndexerTest is Test {
+ // ─────────────────────────────────────────────────────────────────────────
+ // Off-chain indexer model — populated only from emitted logs
+ // ─────────────────────────────────────────────────────────────────────────
+
+ struct IdxOffer {
+ bool exists;
+ bytes32 offerId; // from OfferPosted's indexed topic
+ address offeror;
+ address spvAddress;
+ uint8 side;
+ address certPrinter;
+ uint256 tokenId;
+ address paymentToken;
+ uint256 units;
+ uint256 consideration;
+ uint8 exemptionPathway;
+ uint256 validUntil;
+ address integrator;
+ uint8 status; // OfferStatus
+ uint256 unitsAccepted;
+ uint256 paymentAccepted;
+ uint256 unitsFinalized;
+ bytes32[] settlementAgreementIds; // accumulated from each OfferAccepted (push-only, mirrors on-chain)
+ bytes32 templateId;
+ string buyerName;
+ uint8 buyerHostingMode;
+ address adminMultisig;
+ bytes counterpartyRestrictions;
+ address[] thresholdConditions;
+ address[] closingConditions;
+ // additionalTerms is intentionally not indexed (human/legal-read only, not emitted).
+ }
+
+ struct IdxSettlement {
+ bool exists;
+ bytes32 offerId;
+ address counterparty;
+ address paymentToken;
+ uint256 paymentAmount;
+ uint256 units;
+ uint256 tokenId; // seller's Ledger Entry Token (from OfferAccepted; acceptor-supplied for bids)
+ uint256 expiry; // escrow settlement deadline (agreementExpiry from OfferAccepted)
+ string buyerName; // per-settlement materialization (from OfferAccepted)
+ uint8 buyerHostingMode;
+ address adminMultisig;
+ bytes openEndorsementSig; // per-settlement endorsement used (from OfferAccepted)
+ uint8 status; // SecondaryEscrowStatus
+ address feeDestination; // escrow routing: snapshotted from the offer's integrator
+ address seller; // from Finalized
+ address buyer; // from Finalized
+ uint256 fee; // realized total fee (from SecondaryFeeDistributed; 0 if none)
+ uint256 integratorFee;
+ uint256 platformFee;
+ address creditedIntegrator; // realized fee recipient (zero = platform-only)
+ // Stock-ledger columns, populated from SecondaryTransferExecuted (emitted by the IssuanceManager).
+ bool transferred; // a secondary-transfer fired for this settlement
+ uint256 surrenderedTokenId; // seller's Ledger Entry Token (CERTIFICATES SURRENDERED → CERTIF NO.)
+ uint256 issuedTokenId; // buyer's newly minted Ledger Entry Token (CERTIFICATES ISSUED → CERTIF NO.)
+ bool sellerVoided; // full sale (seller cert voided) vs partial (decremented)
+ }
+
+ // Per-certificate issuance row — one per minted Ledger Entry Token, whether a primary issue or a
+ // secondary-trade mint. Together with sharesHeld this turns the settlement log into a full stock-transfer
+ // ledger: issuance rows open positions, SecondaryTransferExecuted moves them between holders.
+ struct IdxIssuance {
+ bool exists;
+ uint256 tokenId; // CERTIFICATES ISSUED → CERTIF NO.
+ address holder; // registered owner at mint (NAME OF SHAREHOLDER, address) — from CertificateAssigned
+ string holderName; // registered owner name at mint — from CertificateAssigned
+ uint256 units; // NO. OF SHARES — from CertificateCreated.details.unitsRepresented
+ address certPrinter;
+ bool originalIssue; // true until a SecondaryTransferExecuted claims this as a buyer's new cert
+ }
+
+ // A row of the Stock Transfer Ledger itself — the column set a Delaware corporation keeps, minus the
+ // shareholder's off-chain mailing address and the tax-stamp value (neither lives on chain). One row per
+ // certificate: opened from the issuance (CERTIFICATES ISSUED side + the new holder's running balance),
+ // then enriched with the surrender side from the matching SecondaryTransferExecuted.
+ struct IdxLedgerRow {
+ uint256 rowNo; // NO. (sequential)
+ bool originalIssue; // FROM WHOM — "If Original Issue Enter As Such"
+ string shareholderName; // NAME OF SHAREHOLDER (the issuee/new holder)
+ address shareholder; // new holder (TO WHOM TRANSFERRED); off-chain ADDRESS intentionally omitted
+ uint256 issuedCertNo; // CERTIFICATES ISSUED → CERTIF NO.
+ uint256 issuedShares; // CERTIFICATES ISSUED → NO. OF SHARES
+ address fromWhom; // FROM WHOM TRANSFERRED (seller); 0 for an original issue
+ uint256 amountPaid; // AMOUNT PAID THEREON
+ address paymentToken; // token the consideration was paid in
+ uint256 surrenderedCertNo; // CERTIFICATES SURRENDERED → CERTIF NO. (0 for an original issue)
+ uint256 surrenderedShares; // CERTIFICATES SURRENDERED → NO. SHARES
+ bool sellerVoided; // full (voided) vs partial (decremented) surrender
+ uint256 sharesHeldByShareholder; // NUMBER OF SHARES HELD (issued side) — new holder's balance after this row
+ uint256 sharesHeldBySeller; // NUMBER OF SHARES HELD (surrender side) — seller's balance after this row; 0 for an original issue
+ uint256 transferDate; // DATE OF TRANSFER (index-time block timestamp; a real indexer reads the log's)
+ }
+
+ mapping(bytes32 => IdxOffer) internal idxOffers;
+ mapping(bytes32 => IdxSettlement) internal idxSettlements;
+ mapping(uint256 => IdxIssuance) internal idxIssuances;
+ IdxLedgerRow[] internal idxTransferLedger;
+ bytes32[] internal idxOfferIds;
+ bytes32[] internal idxSettlementIds;
+ uint256[] internal idxIssuedTokenIds;
+ mapping(address => uint256) internal sharesHeld; // NUMBER OF SHARES HELD (running per-holder balance)
+ // +1-based row slots into idxTransferLedger (0 == absent), keyed by tokenId for the internal open→enrich
+ // linkage. Reads address the ledger by row index directly.
+ mapping(uint256 => uint256) internal idxLedgerRowByToken;
+
+ // Event topic0 hashes taken straight from the declarations, so they can't drift from the emitted signatures.
+ bytes32 immutable TOPIC_OFFER_POSTED = ISecondaryTradeStorage.OfferPosted.selector;
+ bytes32 immutable TOPIC_OFFER_CANCELLED = ISecondaryTradeStorage.OfferCancelled.selector;
+ bytes32 immutable TOPIC_OFFER_ACCEPTED = ISecondaryTradeStorage.OfferAccepted.selector;
+ bytes32 immutable TOPIC_FINALIZED = ISecondaryTradeStorage.SecondaryTradeAgreementFinalized.selector;
+ bytes32 immutable TOPIC_VOIDED = ISecondaryTradeStorage.SecondaryTradeAgreementVoided.selector;
+ bytes32 immutable TOPIC_FEE = ISecondaryTradeStorage.SecondaryFeeDistributed.selector;
+ // Emitted by the IssuanceManager (not the DealManager), so the indexer also watches that emitter.
+ bytes32 immutable TOPIC_SECONDARY_TRANSFER = IIssuanceManager.SecondaryTransferExecuted.selector;
+ // Primary/secondary mint events that seed the issuance rows + shares-held balance. CertificateCreated
+ // (units, emitter = IssuanceManager) is paired with CertificateAssigned (holder, emitter = CyberCertPrinter).
+ bytes32 immutable TOPIC_CERT_CREATED = IIssuanceManager.CertificateCreated.selector;
+ bytes32 immutable TOPIC_CERT_ASSIGNED = CyberCertPrinter.CertificateAssigned.selector;
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Chain fixtures (mirrors DealManagerSecondaryTradeTest setUp)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ bytes32 constant corpSalt = keccak256("DealManagerSecondaryTradeIndexerTest.corp");
+
+ bytes32 constant imSalt = keccak256("DealManagerSecondaryTradeIndexerTest.im");
+
+ address public owner;
+ uint256 public ownerKey;
+ address public seller;
+ uint256 public sellerKey;
+ address public buyer;
+ uint256 public buyerKey;
+ address public keeper;
+
+ SecERC20Mock public paymentToken;
+ ICyberCertPrinter public certPrinter;
+ IssuanceManager public im;
+ CyberAgreementRegistry public registry;
+ MockCyberCorpForIM public corp;
+ DealManagerFactory public dmFactory;
+ DealManager public dm;
+ BorgAuth public auth;
+
+ bytes32 public constant TEMPLATE_ID = bytes32(0);
+ string public constant TEMPLATE_URI = "ipfs://secondary-template";
+
+ uint256 public constant CONSIDERATION = 10 ether;
+ uint256 public constant UNITS = 100;
+ uint256 public sellerTokenId;
+
+ function setUp() public {
+ (owner, ownerKey) = makeAddrAndKey("owner");
+ (seller, sellerKey) = makeAddrAndKey("seller");
+ (buyer, buyerKey) = makeAddrAndKey("buyer");
+ keeper = makeAddr("keeper");
+
+ paymentToken = new SecERC20Mock();
+ auth = new BorgAuth(owner);
+ corp = new MockCyberCorpForIM();
+
+ // Real IssuanceManager + CyberCertPrinter, deployed through the IssuanceManagerFactory beacon stack
+ // (mirrors IssuanceManagerSecondaryTransferTest), so the secondary-transfer logs are the real ones.
+ IssuanceManagerFactory imFactory = IssuanceManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new IssuanceManagerFactory()),
+ abi.encodeWithSelector(
+ IssuanceManagerFactory.initialize.selector,
+ address(auth),
+ new IssuanceManager(),
+ new CyberCertPrinter(),
+ new CyberScrip()
+ )
+ )
+ )
+ );
+ im = IssuanceManager(imFactory.deployIssuanceManager(imSalt));
+ im.initialize(address(auth), address(corp), address(new MockUriBuilderForIM()), address(imFactory));
+
+ registry = CyberAgreementRegistry(
+ address(
+ new ERC1967Proxy(
+ address(new CyberAgreementRegistry()),
+ abi.encodeWithSelector(CyberAgreementRegistry.initialize.selector, address(auth))
+ )
+ )
+ );
+ vm.prank(owner);
+ registry.createTemplate(TEMPLATE_ID, "Secondary", TEMPLATE_URI, new string[](0), new string[](0));
+
+ dmFactory = DealManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new DealManagerFactory()),
+ abi.encodeWithSelector(
+ DealManagerFactory.initialize.selector, address(auth), address(new DealManager())
+ )
+ )
+ )
+ );
+
+ dm = DealManager(dmFactory.deployDealManager(corpSalt));
+ dm.initialize(address(auth), address(corp), address(registry), address(im), address(dmFactory));
+ // The DealManager invokes the IssuanceManager's owner-gated reservation and secondary-transfer
+ // entry points, so it needs the same role the real onboarding grants it.
+ vm.prank(owner);
+ auth.updateRole(address(dm), 99);
+
+ // Real seller Ledger Entry Token, minted through the IssuanceManager with UNITS represented.
+ // Record the primary-issuance logs and index them, so the ledger opens with the original-issue row
+ // (the seller's founding cert) and the shares-held baseline before any secondary trading.
+ vm.recordLogs();
+ vm.startPrank(owner);
+ certPrinter = ICyberCertPrinter(
+ im.createCertPrinter(
+ new string[](0),
+ "Indexer Cert",
+ "ICERT",
+ "uri://cert",
+ SecurityClass.CommonStock,
+ SecuritySeries.SeriesA,
+ address(0)
+ )
+ );
+ sellerTokenId =
+ im.createCertAndAssignWithName(address(certPrinter), seller, _sellerCertDetails(UNITS), "Alice", "", block.timestamp);
+ vm.stopPrank();
+ _index(vm.getRecordedLogs());
+
+ // The ledger must open with the original-issue row — the founding grant, before any trading:
+ //
+ // NO. | CERTIFICATE ISSUED | CERTIFICATE SURRENDERED
+ // | SHAREHOLDER | CERT# | SHARES | HELD | PAID | FROM WHOM | CERT# | SHARES | VOIDED? | HELD
+ // -----+-------------+-------+--------+------+--------+-----------------+-------+--------+---------+-----
+ // 0 | Alice | 0 | 100 | 100 | 0 | — (orig. issue) | — | 0 | no | —
+ _assertLedgerRow(0, true, seller, "Alice", sellerTokenId, UNITS, address(0), 0, 0, false, 0, address(0), UNITS, 0);
+
+ paymentToken.mint(buyer, CONSIDERATION * 10);
+ vm.prank(buyer);
+ paymentToken.approve(address(dm), type(uint256).max);
+
+ paymentToken.mint(seller, CONSIDERATION * 10);
+ vm.prank(seller);
+ paymentToken.approve(address(dm), type(uint256).max);
+ }
+
+ function _sellerCertDetails(uint256 units) internal pure returns (CertificateDetails memory) {
+ return CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 1000,
+ issuerUSDValuationAtTimeOfInvestment: 10000,
+ unitsRepresented: units,
+ legalDetails: "",
+ extensionData: bytes("")
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Trade-lifecycle helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _defaultSellOfferParams() internal view returns (PostOfferParams memory p) {
+ p = PostOfferParams({
+ side: OfferSide.SELL,
+ certPrinter: address(certPrinter),
+ tokenId: sellerTokenId,
+ units: UNITS,
+ paymentToken: address(paymentToken),
+ consideration: CONSIDERATION,
+ exemptionPathway: ExemptionPathway.SECTION_4A7,
+ validUntil: block.timestamp + 1 days,
+ counterpartyRestrictions: "",
+ additionalTerms: "",
+ integrator: address(0),
+ templateId: bytes32(0),
+ salt: uint256(keccak256("indexerSellOffer")),
+ globalValues: new string[](0),
+ offerorPartyValues: new string[](0),
+ offerorAgreementSig: "",
+ openEndorsementSig: "sellerEndorsement",
+ buyerName: "",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0)
+ });
+ }
+
+ function _defaultBuyOfferParams() internal view returns (PostOfferParams memory p) {
+ p = PostOfferParams({
+ side: OfferSide.BUY,
+ certPrinter: address(certPrinter),
+ tokenId: 0,
+ units: UNITS,
+ paymentToken: address(paymentToken),
+ consideration: CONSIDERATION,
+ exemptionPathway: ExemptionPathway.SECTION_4A7,
+ validUntil: block.timestamp + 1 days,
+ counterpartyRestrictions: "",
+ additionalTerms: "",
+ integrator: address(0),
+ templateId: bytes32(0),
+ salt: uint256(keccak256("indexerBid")),
+ globalValues: new string[](0),
+ offerorPartyValues: new string[](0),
+ offerorAgreementSig: "",
+ openEndorsementSig: "",
+ buyerName: "Test Buyer",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0)
+ });
+ }
+
+ function _postSellOffer() internal returns (bytes32 offerId) {
+ vm.prank(seller);
+ offerId = dm.postOffer(_defaultSellOfferParams());
+ }
+
+ function _postBid() internal returns (bytes32 offerId) {
+ vm.prank(buyer);
+ offerId = dm.postOffer(_defaultBuyOfferParams());
+ }
+
+ function _acceptSellOfferPartial(bytes32 offerId, uint256 units) internal returns (bytes32 settlementId) {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: units,
+ buyerName: "Bob",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ settlementId = dm.acceptOffer(p);
+ }
+
+ function _acceptSellOfferPartialAdministered(bytes32 offerId, uint256 units, address adminMultisig)
+ internal
+ returns (bytes32 settlementId)
+ {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: units,
+ buyerName: "Bob",
+ buyerHostingMode: HostingMode.ADMINISTERED,
+ adminMultisig: adminMultisig,
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ settlementId = dm.acceptOffer(p);
+ }
+
+ function _acceptBidPartial(bytes32 offerId, uint256 units) internal returns (bytes32 settlementId) {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: units,
+ buyerName: "Bob",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: sellerTokenId,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, seller, sellerKey),
+ openEndorsementSig: "sellerEndorsement"
+ });
+ vm.prank(seller);
+ settlementId = dm.acceptOffer(p);
+ }
+
+ function _voidSettlementBothParties(bytes32 settlementId) internal {
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+ vm.prank(seller);
+ dm.voidSecondaryTradeAgreement(settlementId, seller, "");
+ }
+
+ function _agreementSig(bytes32 agreementId, string[] memory partyValues, uint256 key)
+ internal
+ view
+ returns (bytes memory)
+ {
+ return CyberAgreementUtils.signAgreementTypedData(
+ vm,
+ registry.DOMAIN_SEPARATOR(),
+ registry.SIGNATUREDATA_TYPEHASH(),
+ agreementId,
+ TEMPLATE_URI,
+ new string[](0),
+ new string[](0),
+ new string[](0),
+ partyValues,
+ key
+ );
+ }
+
+ function _acceptorSig(bytes32 offerId, address acceptor, uint256 key) internal view returns (bytes memory) {
+ Offer memory o = dm.getOffer(offerId);
+ bytes32 settlementSalt = keccak256(abi.encodePacked(o.salt, o.settlementAgreementIds.length));
+ address[] memory parties = new address[](2);
+ parties[0] = o.offeror;
+ parties[1] = acceptor;
+ bytes32 settlementId = keccak256(abi.encode(o.templateId, uint256(settlementSalt), o.globalValues, parties));
+ return _agreementSig(settlementId, new string[](0), key);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // The indexer — consumes logs only
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _index(Vm.Log[] memory logs) internal {
+ for (uint256 i = 0; i < logs.length; i++) {
+ Vm.Log memory log = logs[i];
+ // The DealManager's order-book/settlement events, the IssuanceManager's secondary-transfer and
+ // certificate-creation events, and the CyberCertPrinter's assignment events; everything else
+ // (ERC20, registry, …) is ignored.
+ if (
+ (log.emitter != address(dm) && log.emitter != address(im) && log.emitter != address(certPrinter))
+ || log.topics.length == 0
+ ) continue;
+ bytes32 topic = log.topics[0];
+ if (topic == TOPIC_OFFER_POSTED) _handleOfferPosted(log);
+ else if (topic == TOPIC_OFFER_ACCEPTED) _handleOfferAccepted(log);
+ else if (topic == TOPIC_OFFER_CANCELLED) _handleOfferCancelled(log);
+ else if (topic == TOPIC_FINALIZED) _handleFinalized(log);
+ else if (topic == TOPIC_VOIDED) _handleVoided(log);
+ else if (topic == TOPIC_FEE) _handleFee(log);
+ else if (topic == TOPIC_SECONDARY_TRANSFER) _handleSecondaryTransfer(log);
+ else if (topic == TOPIC_CERT_ASSIGNED) _handleCertificateAssigned(log);
+ else if (topic == TOPIC_CERT_CREATED) _handleCertificateCreated(log);
+ }
+ }
+
+ function _addr(bytes32 topic) private pure returns (address) {
+ return address(uint160(uint256(topic)));
+ }
+
+ function _handleOfferPosted(Vm.Log memory log) private {
+ bytes32 offerId = log.topics[1];
+ IdxOffer storage o = idxOffers[offerId];
+ require(!o.exists, "indexer: offer posted twice");
+
+ // Decode the flat OfferPosted data tuple straight into the offer record (order matches the event).
+ (
+ o.spvAddress,
+ o.side,
+ o.tokenId,
+ o.units,
+ o.paymentToken,
+ o.consideration,
+ o.exemptionPathway,
+ o.validUntil,
+ o.integrator,
+ o.templateId,
+ o.buyerName,
+ o.buyerHostingMode,
+ o.adminMultisig,
+ o.counterpartyRestrictions,
+ o.thresholdConditions,
+ o.closingConditions
+ ) =
+ abi.decode(
+ log.data,
+ (
+ address,
+ uint8,
+ uint256,
+ uint256,
+ address,
+ uint256,
+ uint8,
+ uint256,
+ address,
+ bytes32,
+ string,
+ uint8,
+ address,
+ bytes,
+ address[],
+ address[]
+ )
+ );
+
+ o.exists = true;
+ o.offerId = offerId;
+ o.offeror = _addr(log.topics[2]);
+ o.certPrinter = _addr(log.topics[3]);
+ o.status = uint8(OfferStatus.LIVE);
+ idxOfferIds.push(offerId);
+ }
+
+ function _handleOfferAccepted(Vm.Log memory log) private {
+ bytes32 offerId = log.topics[1];
+ bytes32 settlementId = log.topics[2];
+ address acceptor = _addr(log.topics[3]);
+ (
+ uint256 units,
+ address payToken,
+ uint256 paymentAmount,
+ uint256 tokenId,
+ uint256 expiry,
+ string memory buyerName,
+ uint8 buyerHostingMode,
+ address adminMultisig,
+ bytes memory openEndorsementSig
+ ) = abi.decode(log.data, (uint256, address, uint256, uint256, uint256, string, uint8, address, bytes));
+
+ IdxSettlement storage s = idxSettlements[settlementId];
+ require(!s.exists, "indexer: settlement accepted twice");
+ s.exists = true;
+ s.offerId = offerId;
+ s.counterparty = acceptor;
+ s.paymentToken = payToken;
+ s.paymentAmount = paymentAmount;
+ s.units = units;
+ s.tokenId = tokenId;
+ s.expiry = expiry;
+ s.buyerName = buyerName;
+ s.buyerHostingMode = buyerHostingMode;
+ s.adminMultisig = adminMultisig;
+ s.openEndorsementSig = openEndorsementSig;
+ s.status = uint8(SecondaryEscrowStatus.ACCEPTED);
+ idxSettlementIds.push(settlementId);
+
+ IdxOffer storage o = idxOffers[offerId];
+ o.settlementAgreementIds.push(settlementId);
+ // escrow.feeDestination is snapshotted from the offer's resolved integrator at acceptance.
+ s.feeDestination = o.integrator;
+ o.unitsAccepted += units;
+ o.paymentAccepted += paymentAmount;
+ o.status =
+ o.unitsAccepted >= o.units ? uint8(OfferStatus.FULLY_ACCEPTED) : uint8(OfferStatus.PARTIALLY_ACCEPTED);
+ }
+
+ function _handleOfferCancelled(Vm.Log memory log) private {
+ idxOffers[log.topics[1]].status = uint8(OfferStatus.CANCELLED);
+ }
+
+ function _handleFinalized(Vm.Log memory log) private {
+ bytes32 agreementId = log.topics[1];
+ (address sellerAddr, address buyerAddr, uint256 units, uint256 consideration) =
+ abi.decode(log.data, (address, address, uint256, uint256));
+
+ IdxSettlement storage s = idxSettlements[agreementId];
+ s.status = uint8(SecondaryEscrowStatus.FINALIZED);
+ s.seller = sellerAddr;
+ s.buyer = buyerAddr;
+ // Cross-event consistency: the finalized units/consideration must equal the funded settlement.
+ require(units == s.units, "indexer: finalized units mismatch");
+ require(consideration == s.paymentAmount, "indexer: finalized consideration mismatch");
+
+ IdxOffer storage o = idxOffers[s.offerId];
+ o.unitsFinalized += s.units;
+ if (o.status != uint8(OfferStatus.CANCELLED) && o.unitsFinalized == o.units) {
+ o.status = uint8(OfferStatus.FINALIZED);
+ }
+ }
+
+ function _handleVoided(Vm.Log memory log) private {
+ bytes32 agreementId = log.topics[1];
+ IdxSettlement storage s = idxSettlements[agreementId];
+ s.status = uint8(SecondaryEscrowStatus.VOIDED);
+
+ IdxOffer storage o = idxOffers[s.offerId];
+ o.unitsAccepted -= s.units;
+ o.paymentAccepted -= s.paymentAmount;
+ if (o.status != uint8(OfferStatus.CANCELLED) && o.status != uint8(OfferStatus.FINALIZED)) {
+ o.status = o.unitsAccepted == 0 ? uint8(OfferStatus.LIVE) : uint8(OfferStatus.PARTIALLY_ACCEPTED);
+ }
+ }
+
+ function _handleFee(Vm.Log memory log) private {
+ IdxSettlement storage s = idxSettlements[log.topics[1]];
+ s.creditedIntegrator = _addr(log.topics[3]);
+ (s.fee, s.integratorFee, s.platformFee) = abi.decode(log.data, (uint256, uint256, uint256));
+ }
+
+ function _handleSecondaryTransfer(Vm.Log memory log) private {
+ // topics: [0]=sig, [1]=agreementId, [2]=certPrinter, [3]=buyer
+ IdxSettlement storage s = idxSettlements[log.topics[1]];
+ address buyerAddr = _addr(log.topics[3]);
+ // data: sellerTokenId, buyerTokenId, seller, units, sellerUnitsAfter, buyerUnitsAfter, sellerVoided, buyerTokenIsMinted
+ (uint256 surrenderedTokenId, uint256 issuedTokenId, address sellerAddr, uint256 units,,, bool sellerVoided, bool buyerTokenIsMinted) =
+ abi.decode(log.data, (uint256, uint256, address, uint256, uint256, uint256, bool, bool));
+ s.transferred = true;
+ s.surrenderedTokenId = surrenderedTokenId;
+ s.issuedTokenId = issuedTokenId;
+ s.sellerVoided = sellerVoided;
+ // NUMBER OF SHARES HELD: the seller surrenders `units` on every fill.
+ sharesHeld[sellerAddr] -= units;
+
+ uint256 slot = idxLedgerRowByToken[issuedTokenId];
+ require(slot != 0, "indexer: transfer before issuance");
+
+ if (buyerTokenIsMinted) {
+ // The cert was just minted for this fill (a fresh secondary issue): update the row
+ // CertificateCreated created first (with buyer info) with the SURRENDER side info, turning a
+ // presumed primary issuance into a secondary transfer.
+ idxIssuances[issuedTokenId].originalIssue = false;
+ IdxLedgerRow storage r = idxTransferLedger[slot - 1];
+ r.originalIssue = false;
+ r.fromWhom = sellerAddr;
+ r.surrenderedCertNo = surrenderedTokenId;
+ r.surrenderedShares = units;
+ r.sellerVoided = sellerVoided;
+ r.amountPaid = s.paymentAmount;
+ r.paymentToken = s.paymentToken;
+ r.sharesHeldBySeller = sharesHeld[sellerAddr];
+ } else {
+ // Accumulation into a pre-existing cert — the buyer's earlier secondary cert OR their pre-existing
+ // PRIMARY cert: no new cert was minted (so no CertificateCreated credited the buyer) and the
+ // existing issue row keeps its original-issue status, but the units still moved — open a fresh
+ // transfer row onto the same issued cert number and credit the buyer here.
+ sharesHeld[buyerAddr] += units;
+ uint256 rowIndex = idxTransferLedger.length;
+ idxTransferLedger.push(
+ IdxLedgerRow({
+ rowNo: rowIndex,
+ originalIssue: false,
+ shareholderName: s.buyerName,
+ shareholder: buyerAddr,
+ issuedCertNo: issuedTokenId,
+ issuedShares: units,
+ fromWhom: sellerAddr,
+ amountPaid: s.paymentAmount,
+ paymentToken: s.paymentToken,
+ surrenderedCertNo: surrenderedTokenId,
+ surrenderedShares: units,
+ sellerVoided: sellerVoided,
+ sharesHeldByShareholder: sharesHeld[buyerAddr],
+ sharesHeldBySeller: sharesHeld[sellerAddr],
+ transferDate: block.timestamp
+ })
+ );
+ }
+ }
+
+ function _handleCertificateAssigned(Vm.Log memory log) private {
+ // topics: [0]=sig, [1]=tokenId, [2]=newOwner; data: (newOwnerName, issuerName). Fires before the
+ // paired CertificateCreated, so it stashes the holder for that handler to credit.
+ uint256 tokenId = uint256(log.topics[1]);
+ (string memory ownerName,) = abi.decode(log.data, (string, string));
+ IdxIssuance storage iss = idxIssuances[tokenId];
+ iss.holder = _addr(log.topics[2]);
+ iss.holderName = ownerName;
+ }
+
+ function _handleCertificateCreated(Vm.Log memory log) private {
+ // topics: [0]=sig, [1]=tokenId, [2]=certificate; data: (amount, cap, CertificateDetails).
+ uint256 tokenId = uint256(log.topics[1]);
+ (,, CertificateDetails memory details) = abi.decode(log.data, (uint256, uint256, CertificateDetails));
+ IdxIssuance storage iss = idxIssuances[tokenId];
+ // The primary-issue path emits CertificateCreated twice for the same cert; credit/record it once.
+ if (iss.exists) return;
+ iss.exists = true;
+ iss.tokenId = tokenId;
+ iss.certPrinter = _addr(log.topics[2]);
+ iss.units = details.unitsRepresented;
+ iss.originalIssue = true; // flipped to false if a later SecondaryTransferExecuted claims this cert
+ idxIssuedTokenIds.push(tokenId);
+ sharesHeld[iss.holder] += details.unitsRepresented;
+
+ // Open the Stock Transfer Ledger row for this cert: the CERTIFICATES ISSUED side and the holder's
+ // running balance. A secondary mint's row is later enriched with the surrender side; an original issue
+ // stays a standalone opening row.
+ uint256 rowIndex = idxTransferLedger.length;
+ idxLedgerRowByToken[tokenId] = rowIndex + 1;
+ idxTransferLedger.push(
+ IdxLedgerRow({
+ rowNo: rowIndex,
+ originalIssue: true,
+ shareholderName: iss.holderName,
+ shareholder: iss.holder,
+ issuedCertNo: tokenId,
+ issuedShares: details.unitsRepresented,
+ fromWhom: address(0),
+ amountPaid: 0,
+ paymentToken: address(0),
+ surrenderedCertNo: 0,
+ surrenderedShares: 0,
+ sellerVoided: false,
+ sharesHeldByShareholder: sharesHeld[iss.holder],
+ sharesHeldBySeller: 0, // no surrender on an original issue
+ transferDate: block.timestamp
+ })
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Reconstruction assertions — indexer state vs on-chain truth
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _assertOfferReconstructed(bytes32 offerId) internal view {
+ IdxOffer storage o = idxOffers[offerId];
+ Offer memory c = dm.getOffer(offerId);
+ assertTrue(o.exists, "offer reconstructed from logs");
+ assertEq(o.offerId, c.offerId, "offerId");
+ assertEq(o.offeror, c.offeror, "offeror");
+ assertEq(o.spvAddress, c.spvAddress, "spvAddress");
+ assertEq(o.side, uint8(c.side), "side");
+ assertEq(o.certPrinter, c.certPrinter, "certPrinter");
+ assertEq(o.tokenId, c.tokenId, "tokenId");
+ assertEq(o.paymentToken, c.paymentToken, "paymentToken");
+ assertEq(o.units, c.units, "units");
+ assertEq(o.consideration, c.consideration, "consideration");
+ assertEq(o.exemptionPathway, uint8(c.exemptionPathway), "exemptionPathway");
+ assertEq(o.validUntil, c.validUntil, "validUntil");
+ assertEq(o.integrator, c.integrator, "integrator");
+ assertEq(o.status, uint8(c.status), "offer status");
+ assertEq(o.unitsAccepted, c.unitsAccepted, "unitsAccepted");
+ assertEq(o.paymentAccepted, c.paymentAccepted, "paymentAccepted");
+ assertEq(o.unitsFinalized, c.unitsFinalized, "unitsFinalized");
+ assertEq(o.settlementAgreementIds.length, c.settlementAgreementIds.length, "settlementAgreementIds length");
+ for (uint256 i = 0; i < o.settlementAgreementIds.length; i++) {
+ assertEq(o.settlementAgreementIds[i], c.settlementAgreementIds[i], "settlementAgreementId");
+ }
+ assertEq(o.templateId, c.templateId, "templateId");
+ assertEq(o.buyerName, c.buyerName, "buyerName");
+ assertEq(o.buyerHostingMode, uint8(c.buyerHostingMode), "buyerHostingMode");
+ assertEq(o.adminMultisig, c.adminMultisig, "adminMultisig");
+ assertEq(o.counterpartyRestrictions, c.counterpartyRestrictions, "counterpartyRestrictions");
+ assertEq(o.thresholdConditions.length, c.thresholdConditions.length, "thresholdConditions length");
+ for (uint256 i = 0; i < o.thresholdConditions.length; i++) {
+ assertEq(o.thresholdConditions[i], c.thresholdConditions[i], "thresholdCondition");
+ }
+ assertEq(o.closingConditions.length, c.closingConditions.length, "closingConditions length");
+ for (uint256 i = 0; i < o.closingConditions.length; i++) {
+ assertEq(o.closingConditions[i], c.closingConditions[i], "closingCondition");
+ }
+ }
+
+ function _assertSettlementReconstructed(bytes32 settlementId) internal view {
+ IdxSettlement storage s = idxSettlements[settlementId];
+ SecondaryEscrow memory c = dm.getSecondaryEscrow(settlementId);
+ assertTrue(s.exists, "settlement reconstructed from logs");
+ assertEq(s.offerId, c.offerId, "settlement offerId backlink");
+ assertEq(s.counterparty, c.counterparty, "settlement counterparty");
+ assertEq(s.paymentToken, c.paymentToken, "settlement paymentToken");
+ assertEq(s.paymentAmount, c.paymentAmount, "settlement paymentAmount");
+ assertEq(s.units, c.units, "settlement units");
+ assertEq(s.tokenId, c.tokenId, "settlement tokenId");
+ assertEq(s.expiry, c.expiry, "settlement expiry");
+ assertEq(s.buyerName, c.buyerName, "settlement buyerName");
+ assertEq(s.buyerHostingMode, uint8(c.buyerHostingMode), "settlement buyerHostingMode");
+ assertEq(s.adminMultisig, c.adminMultisig, "settlement adminMultisig");
+ assertEq(s.openEndorsementSig, c.openEndorsementSig, "settlement openEndorsementSig");
+ assertEq(s.status, uint8(c.status), "settlement status");
+ assertEq(s.feeDestination, c.feeDestination, "settlement feeDestination");
+ }
+
+ /// @dev Asserts the settlement's Finalized/Fee-derived fields (no on-chain counterpart) match expected
+ /// values. All zero for a settlement that never finalized (ACCEPTED/VOIDED).
+ function _assertSettlementDerived(
+ bytes32 settlementId,
+ address expectedSeller,
+ address expectedBuyer,
+ uint256 expectedFee,
+ uint256 expectedIntegratorFee,
+ uint256 expectedPlatformFee,
+ address expectedCreditedIntegrator
+ ) internal view {
+ IdxSettlement storage s = idxSettlements[settlementId];
+ assertEq(s.seller, expectedSeller, "settlement seller");
+ assertEq(s.buyer, expectedBuyer, "settlement buyer");
+ assertEq(s.fee, expectedFee, "settlement fee");
+ assertEq(s.integratorFee, expectedIntegratorFee, "settlement integratorFee");
+ assertEq(s.platformFee, expectedPlatformFee, "settlement platformFee");
+ assertEq(s.creditedIntegrator, expectedCreditedIntegrator, "settlement creditedIntegrator");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenarios
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // SELL offer, two partial fills, finalize one lot, void the other: walks LIVE → PARTIALLY_ACCEPTED →
+ // FULLY_ACCEPTED and back to PARTIALLY_ACCEPTED, with one FINALIZED and one VOIDED settlement — all
+ // reconstructed from OfferPosted / OfferAccepted / Finalized / Voided alone.
+ function test_Indexer_SellLifecycle_PostAcceptFinalizeVoid() public {
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+
+ vm.recordLogs();
+ bytes32 offerId = _postSellOffer();
+ bytes32 lotA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 lotB = _acceptSellOfferPartial(offerId, unitsB);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotA);
+ _voidSettlementBothParties(lotB);
+
+ _index(vm.getRecordedLogs());
+
+ // Exactly the offer and two settlements were discovered from the logs.
+ assertEq(idxOfferIds.length, 1, "one offer indexed");
+ assertEq(idxSettlementIds.length, 2, "two settlements indexed");
+
+ // Reconstructed status matches chain truth field-by-field.
+ _assertOfferReconstructed(offerId);
+ _assertSettlementReconstructed(lotA);
+ _assertSettlementReconstructed(lotB);
+ // lot A finalized (no fee configured → fee fields zero); lot B voided, so its derived fields stay zero.
+ _assertSettlementDerived(lotA, seller, buyer, 0, 0, 0, address(0));
+ _assertSettlementDerived(lotB, address(0), address(0), 0, 0, 0, address(0));
+
+ // Spell out the end state the indexer derived, purely for documentation.
+ assertEq(idxOffers[offerId].status, uint8(OfferStatus.PARTIALLY_ACCEPTED), "offer back to PARTIALLY_ACCEPTED");
+ assertEq(idxOffers[offerId].unitsAccepted, unitsA, "only the finalized lot's units stay accepted");
+ assertEq(idxOffers[offerId].unitsFinalized, unitsA, "one lot finalized");
+ assertEq(idxSettlements[lotA].status, uint8(SecondaryEscrowStatus.FINALIZED), "lot A FINALIZED");
+ assertEq(idxSettlements[lotB].status, uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED");
+ }
+
+ // BUY offer (bid) counterpart of the sell lifecycle: two partial fills (driven from the seller side),
+ // finalize one lot, void the other — same LIVE → PARTIALLY_ACCEPTED → FULLY_ACCEPTED → PARTIALLY_ACCEPTED
+ // walk, reconstructed from OfferPosted / OfferAccepted / Finalized / Voided alone.
+ function test_Indexer_BuyLifecycle_PostAcceptFinalizeVoid() public {
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+
+ vm.recordLogs();
+ bytes32 offerId = _postBid();
+ bytes32 lotA = _acceptBidPartial(offerId, unitsA);
+ bytes32 lotB = _acceptBidPartial(offerId, unitsB);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotA);
+ _voidSettlementBothParties(lotB);
+
+ _index(vm.getRecordedLogs());
+
+ // Exactly the offer and two settlements were discovered from the logs.
+ assertEq(idxOfferIds.length, 1, "one offer indexed");
+ assertEq(idxSettlementIds.length, 2, "two settlements indexed");
+
+ // Reconstructed status matches chain truth field-by-field.
+ _assertOfferReconstructed(offerId);
+ _assertSettlementReconstructed(lotA);
+ _assertSettlementReconstructed(lotB);
+ // lot A finalized (no fee configured → fee fields zero); lot B voided, so its derived fields stay zero.
+ _assertSettlementDerived(lotA, seller, buyer, 0, 0, 0, address(0));
+ _assertSettlementDerived(lotB, address(0), address(0), 0, 0, 0, address(0));
+
+ // Spell out the end state the indexer derived, purely for documentation.
+ assertEq(idxOffers[offerId].side, uint8(OfferSide.BUY), "bid side reconstructed");
+ assertEq(idxOffers[offerId].status, uint8(OfferStatus.PARTIALLY_ACCEPTED), "offer back to PARTIALLY_ACCEPTED");
+ assertEq(idxOffers[offerId].unitsAccepted, unitsA, "only the finalized lot's units stay accepted");
+ assertEq(idxOffers[offerId].unitsFinalized, unitsA, "one lot finalized");
+ assertEq(idxSettlements[lotA].status, uint8(SecondaryEscrowStatus.FINALIZED), "lot A FINALIZED");
+ assertEq(idxSettlements[lotB].status, uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED");
+ }
+
+ // SELL offer counterpart of the bid cancel: one partial fill, then cancel — the committed lot stays
+ // ACCEPTED while the offer goes CANCELLED, reconstructed from OfferPosted / OfferAccepted / OfferCancelled.
+ function test_Indexer_SellLifecycle_PostAcceptCancel() public {
+ uint256 units = 40;
+
+ vm.recordLogs();
+ bytes32 offerId = _postSellOffer();
+ bytes32 lot = _acceptSellOfferPartial(offerId, units);
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ _index(vm.getRecordedLogs());
+
+ assertEq(idxOfferIds.length, 1, "one offer indexed");
+ assertEq(idxSettlementIds.length, 1, "one settlement indexed");
+
+ _assertOfferReconstructed(offerId);
+ _assertSettlementReconstructed(lot);
+ _assertSettlementDerived(lot, address(0), address(0), 0, 0, 0, address(0)); // never finalized
+
+ assertEq(idxOffers[offerId].side, uint8(OfferSide.SELL), "sell side reconstructed");
+ assertEq(idxOffers[offerId].status, uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(idxOffers[offerId].unitsAccepted, units, "committed lot still counted after cancel");
+ assertEq(idxSettlements[lot].status, uint8(SecondaryEscrowStatus.ACCEPTED), "committed lot survives cancel");
+ }
+
+ // BUY offer (bid), one partial fill, then cancel: the committed lot stays ACCEPTED while the offer goes
+ // CANCELLED — reconstructed from OfferPosted / OfferAccepted / OfferCancelled.
+ function test_Indexer_BuyLifecycle_PostAcceptCancel() public {
+ uint256 units = 40;
+
+ vm.recordLogs();
+ bytes32 offerId = _postBid();
+ bytes32 lot = _acceptBidPartial(offerId, units);
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ _index(vm.getRecordedLogs());
+
+ assertEq(idxOfferIds.length, 1, "one offer indexed");
+ assertEq(idxSettlementIds.length, 1, "one settlement indexed");
+
+ _assertOfferReconstructed(offerId);
+ _assertSettlementReconstructed(lot);
+ _assertSettlementDerived(lot, address(0), address(0), 0, 0, 0, address(0)); // never finalized
+
+ assertEq(idxOffers[offerId].side, uint8(OfferSide.BUY), "bid side reconstructed");
+ assertEq(idxOffers[offerId].status, uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(idxOffers[offerId].unitsAccepted, units, "committed lot still counted after cancel");
+ assertEq(idxSettlements[lot].status, uint8(SecondaryEscrowStatus.ACCEPTED), "committed lot survives cancel");
+ }
+
+ // Non-zero fee finalize: the realized integrator/platform split is reconstructed from
+ // SecondaryFeeDistributed alone and cross-checked against on-chain balances.
+ function test_Indexer_FeeSplit_FromEventsAlone() public {
+ address integrator = makeAddr("integrator");
+ address platform = makeAddr("platform");
+
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 3000); // 30% of the fee to the integrator
+ vm.prank(owner);
+ dmFactory.setDefaultFeeRatio(1000); // 10% ticket fee
+ vm.prank(owner);
+ dmFactory.setPlatformPayable(platform);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_Indexer_FeeSplit_FromEventsAlone"));
+ p.integrator = integrator;
+
+ vm.recordLogs();
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ AcceptOfferParams memory ap = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: "Bob",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ bytes32 settlementId = dm.acceptOffer(ap);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ _index(vm.getRecordedLogs());
+
+ // Offer and settlement reconstruct, including the integrator captured at post time.
+ _assertOfferReconstructed(offerId);
+ _assertSettlementReconstructed(settlementId);
+ assertEq(idxOffers[offerId].integrator, integrator, "integrator captured from OfferPosted");
+ assertEq(idxOffers[offerId].status, uint8(OfferStatus.FINALIZED), "offer FINALIZED");
+
+ // The fee split reconstructed from the event alone.
+ uint256 fee = CONSIDERATION * 1000 / 10000; // 1 ether
+ uint256 expectedIntegratorFee = fee * 3000 / 10000; // 0.3 ether
+ uint256 expectedPlatformFee = fee - expectedIntegratorFee; // 0.7 ether
+ // Sell offer finalized with a fee: seller=offeror, buyer=acceptor, split credited to the integrator.
+ _assertSettlementDerived(
+ settlementId, seller, buyer, fee, expectedIntegratorFee, expectedPlatformFee, integrator
+ );
+
+ IdxSettlement storage s = idxSettlements[settlementId];
+ assertEq(s.integratorFee + s.platformFee, s.fee, "split sums to total");
+
+ // Events agree with reality.
+ assertEq(paymentToken.balanceOf(integrator), s.integratorFee, "integrator balance == reconstructed fee");
+ assertEq(paymentToken.balanceOf(platform), s.platformFee, "platform balance == reconstructed fee");
+ }
+
+ // Stock-ledger reconstruction: a SELL offer filled in two lots (partial then closing) yields two transfer
+ // rows. The buyer's first fill mints a new cert; the second purchase accumulates into that same cert (no new
+ // cert minted), so both transfer rows carry the SAME issued cert number — the default consolidation behavior.
+ function test_Indexer_StockLedger_DirectHosting_MultipleFills() public {
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+
+ vm.recordLogs();
+ bytes32 offerId = _postSellOffer();
+ bytes32 lotA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 lotB = _acceptSellOfferPartial(offerId, unitsB);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotA);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotB);
+
+ _index(vm.getRecordedLogs());
+
+ // Amount paid (consideration) prorates with the units of each partial fill.
+ uint256 paidA = CONSIDERATION * unitsA / UNITS; // 4 ether
+ uint256 paidB = CONSIDERATION * unitsB / UNITS; // 6 ether
+
+ // The full expected Stock Transfer Ledger once both lots settle — row 0 the founding issue (from
+ // setUp), rows 1–2 the two transfers. NUMBER OF SHARES HELD is the row holder's running balance after
+ // that row; the seller's whole position ends up with the buyer, consolidated in a single cert.
+ //
+ // NO. | CERTIFICATE ISSUED | CERTIFICATE SURRENDERED
+ // | SHAREHOLDER | CERT# | SHARES | HELD | PAID | FROM WHOM | CERT# | SHARES | VOIDED? | HELD
+ // -----+-------------+-------+--------+------+--------+-----------------+-------+--------+---------+-----
+ // 0 | Alice | 0 | 100 | 100 | 0 | — (orig. issue) | — | 0 | no | —
+ // 1 | Bob | 1 | 40 | 40 | 4 eth | Alice | 0 | 40 | no | 60
+ // 2 | Bob | 1 | 60 | 100 | 6 eth | Alice | 0 | 60 | yes | 0
+ assertEq(idxTransferLedger.length, 3, "ledger has the original issue + two transfers");
+ // Row 0 — the original issue is unchanged by the trading that followed.
+ _assertLedgerRow(0, true, seller, "Alice", sellerTokenId, UNITS, address(0), 0, 0, false, 0, address(0), UNITS, 0);
+ // Row 1 — lot A, partial sale of 40: seller cert decremented (not voided), buyer's new cert (#1) holds 40.
+ _assertLedgerRow(
+ 1, false, buyer, "Bob", 1, unitsA, seller, sellerTokenId, unitsA, false, paidA, address(paymentToken), unitsA, UNITS - unitsA
+ );
+ // Row 2 — lot B, closing the position: the seller cert is voided, and the 60 units accumulate into Bob's
+ // SAME cert (#1), which now represents the full 100.
+ _assertLedgerRow(
+ 2, false, buyer, "Bob", 1, unitsB, seller, sellerTokenId, unitsB, true, paidB, address(paymentToken), UNITS, UNITS - unitsA - unitsB
+ );
+
+ // Both lots surrender the same origin cert and consolidate into one buyer cert — a coherent chain of title.
+ assertEq(idxSettlements[lotA].surrenderedTokenId, idxSettlements[lotB].surrenderedTokenId, "same origin cert");
+ assertEq(idxSettlements[lotA].issuedTokenId, idxSettlements[lotB].issuedTokenId, "both fills consolidate into one cert");
+
+ // NUMBER OF SHARES HELD: the whole position moved from the seller to the buyer across the two lots.
+ assertEq(sharesHeld[seller], 0, "seller fully divested");
+ assertEq(sharesHeld[buyer], UNITS, "buyer holds the entire position");
+ }
+
+ // Administered-hosting counterpart of the direct-hosting fills: the same SELL offer filled in two lots,
+ // but the buyer custodies through an admin multisig. The Stock Transfer Ledger must still record the BUYER
+ // as the shareholder of record in every row — not the custodian — because the printer registers the buyer
+ // as legal owner while the multisig only holds the NFT.
+ function test_Indexer_StockLedger_AdministeredHosting_MultipleFills() public {
+ address adminMultisig = makeAddr("adminMultisig");
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+
+ vm.recordLogs();
+ bytes32 offerId = _postSellOffer();
+ bytes32 lotA = _acceptSellOfferPartialAdministered(offerId, unitsA, adminMultisig);
+ bytes32 lotB = _acceptSellOfferPartialAdministered(offerId, unitsB, adminMultisig);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotA);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lotB);
+
+ _index(vm.getRecordedLogs());
+
+ // Amount paid (consideration) prorates with the units of each partial fill.
+ uint256 paidA = CONSIDERATION * unitsA / UNITS; // 4 ether
+ uint256 paidB = CONSIDERATION * unitsB / UNITS; // 6 ether
+
+ // On-chain split: the multisig custodies the buyer's (consolidated) cert, but the buyer is the registered
+ // legal owner. Both fills land on the same cert, so the two lookups address one token.
+ assertEq(
+ CyberCertPrinter(address(certPrinter)).ownerOf(idxSettlements[lotA].issuedTokenId),
+ adminMultisig,
+ "lot A custodied by multisig"
+ );
+ assertEq(
+ CyberCertPrinter(address(certPrinter)).ownerOf(idxSettlements[lotB].issuedTokenId),
+ adminMultisig,
+ "lot B custodied by multisig"
+ );
+ assertEq(certPrinter.legalOwnerOf(idxSettlements[lotA].issuedTokenId), buyer, "lot A buyer is legal owner");
+ assertEq(certPrinter.legalOwnerOf(idxSettlements[lotB].issuedTokenId), buyer, "lot B buyer is legal owner");
+
+ // The full expected Stock Transfer Ledger — identical to direct hosting, because the buyer (not the
+ // custodian) is the shareholder of record. Row 0 the founding issue, rows 1–2 the two transfers.
+ //
+ // NO. | CERTIFICATE ISSUED | CERTIFICATE SURRENDERED
+ // | SHAREHOLDER | CERT# | SHARES | HELD | PAID | FROM WHOM | CERT# | SHARES | VOIDED? | HELD
+ // -----+-------------+-------+--------+------+--------+-----------------+-------+--------+---------+-----
+ // 0 | Alice | 0 | 100 | 100 | 0 | — (orig. issue) | — | 0 | no | —
+ // 1 | Bob | 1 | 40 | 40 | 4 eth | Alice | 0 | 40 | no | 60
+ // 2 | Bob | 1 | 60 | 100 | 6 eth | Alice | 0 | 60 | yes | 0
+ assertEq(idxTransferLedger.length, 3, "ledger has the original issue + two transfers");
+ // Row 0 — the original issue is unchanged by the trading that followed.
+ _assertLedgerRow(0, true, seller, "Alice", sellerTokenId, UNITS, address(0), 0, 0, false, 0, address(0), UNITS, 0);
+ // Row 1 — lot A, partial sale of 40: seller cert decremented (not voided), buyer's new cert (#1) holds 40.
+ _assertLedgerRow(
+ 1, false, buyer, "Bob", 1, unitsA, seller, sellerTokenId, unitsA, false, paidA, address(paymentToken), unitsA, UNITS - unitsA
+ );
+ // Row 2 — lot B, closing the position: the seller cert is voided, and the 60 units accumulate into Bob's
+ // SAME cert (#1), which now represents the full 100.
+ _assertLedgerRow(
+ 2, false, buyer, "Bob", 1, unitsB, seller, sellerTokenId, unitsB, true, paidB, address(paymentToken), UNITS, UNITS - unitsA - unitsB
+ );
+
+ // Both lots surrender the same origin cert and consolidate into one buyer cert — a coherent chain of title.
+ assertEq(idxSettlements[lotA].surrenderedTokenId, idxSettlements[lotB].surrenderedTokenId, "same origin cert");
+ assertEq(idxSettlements[lotA].issuedTokenId, idxSettlements[lotB].issuedTokenId, "both fills consolidate into one cert");
+
+ // NUMBER OF SHARES HELD: the whole position moved to the buyer of record; the custodian holds none.
+ assertEq(sharesHeld[seller], 0, "seller fully divested");
+ assertEq(sharesHeld[buyer], UNITS, "buyer holds the entire position of record");
+ assertEq(sharesHeld[adminMultisig], 0, "custodian holds no shares of record");
+ }
+
+ // Accumulation into a buyer's PRE-EXISTING PRIMARY cert: the buyer already holds a founding cert on this
+ // printer before any trading, then buys a lot from the seller. The contract folds the purchase into that
+ // primary cert (no new cert minted), so the indexer must keep the primary issue row an ORIGINAL ISSUE and
+ // open a SEPARATE secondary-transfer row for the purchase — not mis-enrich the founding row.
+ function test_Indexer_StockLedger_AccumulateIntoPreexistingPrimaryCert() public {
+ uint256 buyerPrimaryUnits = 50;
+ uint256 tradeUnits = 40;
+
+ vm.recordLogs();
+ // Bob already holds a founding (primary) cert on this printer, minted before any secondary trade.
+ vm.prank(owner);
+ uint256 buyerPrimaryTokenId = im.createCertAndAssignWithName(
+ address(certPrinter), buyer, _sellerCertDetails(buyerPrimaryUnits), "Bob", "", block.timestamp
+ );
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 lot = _acceptSellOfferPartial(offerId, tradeUnits);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(lot);
+
+ _index(vm.getRecordedLogs());
+
+ uint256 paid = CONSIDERATION * tradeUnits / UNITS; // 4 ether
+
+ // The purchase folds into Bob's pre-existing PRIMARY cert (#1): no new cert minted, so that primary row
+ // stays an original issue and the purchase opens its OWN secondary-transfer row on the same cert number.
+ //
+ // NO. | CERTIFICATE ISSUED | CERTIFICATE SURRENDERED
+ // | SHAREHOLDER | CERT# | SHARES | HELD | PAID | FROM WHOM | CERT# | SHARES | VOIDED? | HELD
+ // -----+-------------+-------+--------+------+--------+-----------------+-------+--------+---------+-----
+ // 0 | Alice | 0 | 100 | 100 | 0 | — (orig. issue) | — | 0 | no | —
+ // 1 | Bob | 1 | 50 | 50 | 0 | — (orig. issue) | — | 0 | no | —
+ // 2 | Bob | 1 | 40 | 90 | 4 eth | Alice | 0 | 40 | no | 60
+ assertEq(idxTransferLedger.length, 3, "ledger has Alice's + Bob's original issues + one transfer");
+ _assertLedgerRow(0, true, seller, "Alice", sellerTokenId, UNITS, address(0), 0, 0, false, 0, address(0), UNITS, 0);
+ // Row 1 — Bob's founding cert stays an original issue, untouched by the trade that folds into it.
+ _assertLedgerRow(
+ 1, true, buyer, "Bob", buyerPrimaryTokenId, buyerPrimaryUnits, address(0), 0, 0, false, 0, address(0), buyerPrimaryUnits, 0
+ );
+ // Row 2 — the purchase: seller cert decremented (not voided), 40 units accumulate into Bob's primary cert.
+ _assertLedgerRow(
+ 2, false, buyer, "Bob", buyerPrimaryTokenId, tradeUnits, seller, sellerTokenId, tradeUnits, false, paid, address(paymentToken), buyerPrimaryUnits + tradeUnits, UNITS - tradeUnits
+ );
+
+ // The issued cert is Bob's own founding cert; only the surrender names the seller's cert.
+ assertEq(idxSettlements[lot].issuedTokenId, buyerPrimaryTokenId, "purchase folded into Bob's primary cert");
+ assertEq(idxSettlements[lot].surrenderedTokenId, sellerTokenId, "seller surrendered the founding cert");
+ // The primary cert keeps its original-issue status despite absorbing a secondary purchase.
+ assertTrue(idxIssuances[buyerPrimaryTokenId].originalIssue, "buyer's cert remains an original issue");
+
+ // NUMBER OF SHARES HELD: the seller's lot moved into Bob's already-held position.
+ assertEq(sharesHeld[seller], UNITS - tradeUnits, "seller decremented by the sold lot");
+ assertEq(sharesHeld[buyer], buyerPrimaryUnits + tradeUnits, "buyer's primary cert grew by the purchase");
+ }
+
+ /// @dev Asserts one Stock Transfer Ledger row (idxTransferLedger[row]) matches expected across every
+ /// reconstructable column. Handles both an original-issue row (no transferor / surrender) and a
+ /// secondary-transfer row. For an original issue pass fromWhom = address(0) and surrendered = (0, 0).
+ function _assertLedgerRow(
+ uint256 row,
+ bool expectedOriginalIssue,
+ address expectedShareholder,
+ string memory expectedShareholderName,
+ uint256 expectedIssuedCert,
+ uint256 expectedIssuedShares,
+ address expectedFromWhom,
+ uint256 expectedSurrenderedCert,
+ uint256 expectedSurrenderedShares,
+ bool expectedVoided,
+ uint256 expectedAmountPaid,
+ address expectedPaymentToken,
+ uint256 expectedSharesHeld,
+ uint256 expectedSellerHeld
+ ) internal view {
+ IdxLedgerRow storage r = idxTransferLedger[row];
+ assertEq(r.rowNo, row, "ledger row no.");
+ assertEq(r.originalIssue, expectedOriginalIssue, "original-issue flag");
+ // NAME OF SHAREHOLDER / TO WHOM TRANSFERRED (the new holder).
+ assertEq(r.shareholder, expectedShareholder, "shareholder (to-whom)");
+ assertEq(r.shareholderName, expectedShareholderName, "shareholder name");
+ // CERTIFICATES ISSUED → CERTIF NO. / NO. OF SHARES.
+ assertEq(r.issuedCertNo, expectedIssuedCert, "issued cert number");
+ assertEq(r.issuedShares, expectedIssuedShares, "issued shares");
+ // FROM WHOM TRANSFERRED (seller; address(0) for an original issue).
+ assertEq(r.fromWhom, expectedFromWhom, "from-whom (seller)");
+ // CERTIFICATES SURRENDERED → CERTIF NO. / NO. SHARES (both 0 for an original issue).
+ assertEq(r.surrenderedCertNo, expectedSurrenderedCert, "surrendered cert number");
+ assertEq(r.surrenderedShares, expectedSurrenderedShares, "surrendered shares");
+ assertEq(r.sellerVoided, expectedVoided, "full-vs-partial flag");
+ // AMOUNT PAID THEREON (consideration) and the token it was paid in.
+ assertEq(r.amountPaid, expectedAmountPaid, "amount paid (consideration)");
+ assertEq(r.paymentToken, expectedPaymentToken, "payment token");
+ // NUMBER OF SHARES HELD (issued side: new holder's balance; surrender side: seller's balance after the
+ // surrender, 0 for an original issue) and DATE OF TRANSFER (recorded).
+ assertEq(r.sharesHeldByShareholder, expectedSharesHeld, "number of shares held (issued side)");
+ assertEq(r.sharesHeldBySeller, expectedSellerHeld, "number of shares held (surrender side)");
+ assertGt(r.transferDate, 0, "transfer date recorded");
+ }
+}
diff --git a/test/DealManagerSecondaryTradeTest.t.sol b/test/DealManagerSecondaryTradeTest.t.sol
new file mode 100644
index 00000000..3a8dc6fd
--- /dev/null
+++ b/test/DealManagerSecondaryTradeTest.t.sol
@@ -0,0 +1,3787 @@
+/* .o.
+ .888.
+ .8"888.
+ .8' `888.
+ .88ooo8888.
+ .8' `888.
+o88o o8888o
+
+
+
+ooo ooooo . ooooo ooooooo ooooo
+`88. .888' .o8 `888' `8888 d8'
+ 888b d'888 .ooooo. .o888oo .oooo. 888 .ooooo. Y888..8P
+ 8 Y88. .P 888 d88' `88b 888 `P )88b 888 d88' `88b `8888'
+ 8 `888' 888 888ooo888 888 .oP"888 888 888ooo888 .8PY888.
+ 8 Y 888 888 .o 888 . d8( 888 888 o 888 .o d8' `888b
+o8o o888o `Y8bod8P' "888" `Y888""8o o888ooooood8 `Y8bod8P' o888o o88888o
+
+
+
+ .oooooo. .o8 .oooooo.
+ d8P' `Y8b "888 d8P' `Y8b
+888 oooo ooo 888oooo. .ooooo. oooo d8b 888 .ooooo. oooo d8b oo.ooooo.
+888 `88. .8' d88' `88b d88' `88b `888""8P 888 d88' `88b `888""8P 888' `88b
+888 `88..8' 888 888 888ooo888 888 888 888 888 888 888 888
+`88b ooo `888' 888 888 888 .o 888 `88b ooo 888 888 888 888 888 .o.
+ `Y8bood8P' .8' `Y8bod8P' `Y8bod8P' d888b `Y8bood8P' `Y8bod8P' d888b 888bod8P' Y8P
+ .o..P' 888
+ `Y8P' o888o
+_______________________________________________________________________________________________________
+
+All software, documentation and other files and information in this repository (collectively, the "Software")
+are copyright MetaLeX Labs, Inc., a Delaware corporation.
+
+All rights reserved.
+
+The Software is proprietary and shall not, in part or in whole, be used, copied, modified, merged, published,
+distributed, transmitted, sublicensed, sold, or otherwise used in any form or by any means, electronic or
+mechanical, including photocopying, recording, or by any information storage and retrieval system,
+except with the express prior written permission of the copyright holder.*/
+pragma solidity 0.8.28;
+
+import {ERC1967Proxy} from "../dependencies/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {ERC20} from "../dependencies/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
+import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
+import {CyberCertPrinter} from "../src/CyberCertPrinter.sol";
+import {SecurityClass, SecuritySeries} from "../src/CyberCorpConstants.sol";
+import {CyberScrip} from "../src/CyberScrip.sol";
+import {DealManager} from "../src/DealManager.sol";
+import {DealManagerFactory} from "../src/DealManagerFactory.sol";
+import {IssuanceManager} from "../src/IssuanceManager.sol";
+import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+import {CertificateDetails, Endorsement, ICyberCertPrinter} from "../src/interfaces/ICyberCertPrinter.sol";
+import {IDealManager} from "../src/interfaces/IDealManager.sol";
+import {BaseSecondaryTradingCondition} from "../src/libs/conditions/BaseSecondaryTradingCondition.sol";
+import {ILexScrowStorage} from "../src/interfaces/ILexScrowStorage.sol";
+import {ISecondaryTradeStorage} from "../src/interfaces/ISecondaryTradeStorage.sol";
+import {BorgAuth} from "../src/libs/auth.sol";
+import {EscrowStatus} from "../src/storage/LexScrowStorage.sol";
+import {
+ AcceptOfferParams,
+ ExemptionPathway,
+ HostingMode,
+ Offer,
+ OfferSide,
+ OfferStatus,
+ PostOfferParams,
+ SecondaryEscrow,
+ SecondaryEscrowStatus
+} from "../src/storage/SecondaryTradeStorage.sol";
+import {CyberAgreementUtils} from "./libs/CyberAgreementUtils.sol";
+// Minimal CyberCorp / uriBuilder fixtures for the real IssuanceManager, shared from IssuanceManagerTest.
+import {
+ MockCyberCorpForIM,
+ MockUriBuilderForIM
+} from "./IssuanceManagerTest.t.sol";
+import {Test, console2} from "forge-std/Test.sol";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Mocks
+// ─────────────────────────────────────────────────────────────────────────────
+
+contract SecERC20Mock is ERC20 {
+ constructor() ERC20("Payment Token", "PAY") {}
+
+ function mint(address to, uint256 amount) public {
+ _mint(to, amount);
+ }
+}
+
+contract SecConditionMock is BaseSecondaryTradingCondition {
+ bool private _pass;
+
+ constructor(bool pass_) {
+ _pass = pass_;
+ }
+
+ function checkCondition(IDealManager, bytes4, bytes32, bytes32) external view override returns (bool) {
+ return _pass;
+ }
+}
+
+// Stateful threshold-condition mock: returns `pass`, which the test flips between acceptance and
+// finalization. Used to prove the threshold set is re-checked at finalize — it passes through
+// posting/acceptance, is then flipped to fail, and must block the asset transfer.
+contract SecFlippableConditionMock is BaseSecondaryTradingCondition {
+ bool public pass;
+
+ constructor(bool pass_) {
+ pass = pass_;
+ }
+
+ function setPass(bool v) external {
+ pass = v;
+ }
+
+ function checkCondition(IDealManager, bytes4, bytes32, bytes32) external view override returns (bool) {
+ return pass;
+ }
+}
+
+// Mirrors a real seller-side threshold condition: Returns false if the
+// offer is unreadable — which is what the pre-fix ordering produced, since the condition loop ran
+// before the offer was stored.
+contract SecOfferReadingConditionMock is BaseSecondaryTradingCondition {
+ function checkCondition(IDealManager dealManager, bytes4, bytes32 offerId, bytes32) external view override returns (bool) {
+ Offer memory o = dealManager.getOffer(offerId);
+ return o.offeror != address(0) && o.certPrinter != address(0);
+ }
+}
+
+// Mirrors a real buyer-facing threshold condition (KYC/accreditation/holder-cap): it short-circuits
+// to `true` at posting when there is no settlement yet (agreementId == 0), and enforces once an acceptor
+// exists. Used to prove acceptOffer re-evaluates threshold conditions (post-fix); pre-fix it never ran here.
+contract SecBuyerFacingConditionMock is BaseSecondaryTradingCondition {
+ bool private _acceptorAllowed;
+
+ constructor(bool acceptorAllowed_) {
+ _acceptorAllowed = acceptorAllowed_;
+ }
+
+ function checkCondition(IDealManager, bytes4, bytes32, bytes32 agreementId) external view override returns (bool) {
+ if (agreementId == bytes32(0)) return true; // posting context: no buyer yet
+ return _acceptorAllowed; // acceptance context: enforce
+ }
+}
+
+contract SecCounterpartyRestrictionsConditionMock is BaseSecondaryTradingCondition {
+ bytes private _expected;
+
+ constructor(bytes memory expected_) {
+ _expected = expected_;
+ }
+
+ function checkCondition(IDealManager dealManager, bytes4, bytes32 offerId, bytes32 agreementId) external view override returns (bool) {
+ if (agreementId == bytes32(0)) return true; // short-circuit to pass if no acceptor yet
+ Offer memory o = dealManager.getOffer(offerId);
+ return keccak256(o.counterpartyRestrictions) == keccak256(_expected); // acceptance: enforce the blob
+ }
+}
+
+// Does not implement ISecondaryTradingCondition (no ERC-165 support): the config setters must reject it.
+contract SecNonConditionMock {}
+
+// Passes when handed any of the selectors it was configured with. Models a real phase-gating condition:
+// each of post/accept has a direct and a relayer overload with distinct selectors, so a condition that gates
+// "the post phase" registers both. Selectors are the library-internal ones conditions actually receive
+// (struct kept by name, not tuple-expanded), e.g. keccak256("postOffer(PostOfferParams)").
+contract SecSelectorAssertingConditionMock is BaseSecondaryTradingCondition {
+ mapping(bytes4 => bool) public accepted;
+
+ constructor(bytes4[] memory selectors) {
+ for (uint256 i = 0; i < selectors.length; i++) accepted[selectors[i]] = true;
+ }
+
+ function checkCondition(IDealManager, bytes4 functionSignature, bytes32, bytes32) external view override returns (bool) {
+ return accepted[functionSignature];
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test contract
+// ─────────────────────────────────────────────────────────────────────────────
+
+contract DealManagerSecondaryTradeTest is Test {
+ bytes32 constant corpSalt = keccak256("DealManagerSecondaryTradeTest.corp");
+
+ address public owner;
+ uint256 public ownerKey;
+ address public seller;
+ uint256 public sellerKey;
+ address public buyer;
+ uint256 public buyerKey;
+ address public keeper;
+ // Unrelated payee used only to assert the secondary path never routes funds to the company payable.
+ address public company;
+
+ bytes32 constant imSalt = keccak256("DealManagerSecondaryTradeTest.im");
+
+ SecERC20Mock public paymentToken;
+ ICyberCertPrinter public certPrinter;
+ IssuanceManager public im;
+ CyberAgreementRegistry public registry;
+ MockCyberCorpForIM public corp;
+ DealManagerFactory public dmFactory;
+ DealManager public dm;
+ BorgAuth public auth;
+
+ // Single template (empty global/party fields) reused for every offer and the primary regression deals.
+ bytes32 public constant TEMPLATE_ID = bytes32(0);
+ string public constant TEMPLATE_URI = "ipfs://secondary-template";
+
+ uint256 public constant CONSIDERATION = 10 ether;
+ uint256 public constant UNITS = 100;
+ uint256 public sellerTokenId;
+
+ // Placeholder counterpartyRestrictions blob (spec §8.1); real encoding is not implemented yet, so any
+ // non-empty value the SecCounterpartyRestrictionsConditionMock can match on suffices.
+ bytes public constant COUNTERPARTY_RESTRICTIONS = "mock counterparty restrictions";
+
+ // Supplemental fields blob (spec §8.1): memorialized only, never enforced — carried by the default
+ // offers purely to prove it round-trips through postOffer/getOffer unchanged.
+ bytes public constant ADDITIONAL_TERMS = "mock additional terms";
+
+ // Seller's open-endorsement signature (spec §7.3.1): opaque blob, not recovered yet.
+ bytes public constant OPEN_ENDORSEMENT_SIG = "sellerEndorsement";
+
+ // Buyer name the acceptor supplies when filling a sell offer (memorialized on the settlement escrow).
+ string public constant SELL_ACCEPT_BUYER_NAME = "Bob";
+
+ function setUp() public {
+ (owner, ownerKey) = makeAddrAndKey("owner");
+ (seller, sellerKey) = makeAddrAndKey("seller");
+ (buyer, buyerKey) = makeAddrAndKey("buyer");
+ keeper = makeAddr("keeper");
+ company = makeAddr("company");
+
+ paymentToken = new SecERC20Mock();
+ auth = new BorgAuth(owner);
+ corp = new MockCyberCorpForIM();
+
+ // Real IssuanceManager + CyberCertPrinter, deployed through the IssuanceManagerFactory beacon stack.
+ IssuanceManagerFactory imFactory = IssuanceManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new IssuanceManagerFactory()),
+ abi.encodeWithSelector(
+ IssuanceManagerFactory.initialize.selector,
+ address(auth),
+ new IssuanceManager(),
+ new CyberCertPrinter(),
+ new CyberScrip()
+ )
+ )
+ )
+ );
+ im = IssuanceManager(imFactory.deployIssuanceManager(imSalt));
+ im.initialize(address(auth), address(corp), address(new MockUriBuilderForIM()), address(imFactory));
+
+ // Real CyberAgreementRegistry behind a proxy, sharing the same BorgAuth.
+ registry = CyberAgreementRegistry(
+ address(
+ new ERC1967Proxy(
+ address(new CyberAgreementRegistry()),
+ abi.encodeWithSelector(CyberAgreementRegistry.initialize.selector, address(auth))
+ )
+ )
+ );
+ // Single reusable template with no global/party fields (matches the empty values used below).
+ vm.prank(owner);
+ registry.createTemplate(TEMPLATE_ID, "Secondary", TEMPLATE_URI, new string[](0), new string[](0));
+
+ dmFactory = DealManagerFactory(
+ address(
+ new ERC1967Proxy(
+ address(new DealManagerFactory()),
+ abi.encodeWithSelector(
+ DealManagerFactory.initialize.selector, address(auth), address(new DealManager())
+ )
+ )
+ )
+ );
+
+ dm = DealManager(dmFactory.deployDealManager(corpSalt));
+ dm.initialize(address(auth), address(corp), address(registry), address(im), address(dmFactory));
+ // The DealManager invokes the IssuanceManager's owner-gated reservation and secondary-transfer
+ // entry points, so it needs the role real onboarding grants it.
+ vm.prank(owner);
+ auth.updateRole(address(dm), 99);
+
+ // Mint the seller's Ledger Entry Token through the real IssuanceManager, with UNITS represented.
+ vm.startPrank(owner);
+ certPrinter = ICyberCertPrinter(
+ im.createCertPrinter(
+ new string[](0),
+ "Secondary Cert",
+ "SCERT",
+ "uri://cert",
+ SecurityClass.CommonStock,
+ SecuritySeries.SeriesA,
+ address(0)
+ )
+ );
+ sellerTokenId = im.createCertAndAssign(address(certPrinter), seller, _sellerCertDetails(UNITS));
+ vm.stopPrank();
+
+ // Fund buyer
+ paymentToken.mint(buyer, CONSIDERATION * 10);
+ vm.prank(buyer);
+ paymentToken.approve(address(dm), type(uint256).max);
+
+ // Fund seller (for bid tests where seller receives nothing upfront)
+ paymentToken.mint(seller, CONSIDERATION * 10);
+ vm.prank(seller);
+ paymentToken.approve(address(dm), type(uint256).max);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function _sellerCertDetails(uint256 units) internal pure returns (CertificateDetails memory) {
+ return CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 1000,
+ issuerUSDValuationAtTimeOfInvestment: 10000,
+ unitsRepresented: units,
+ legalDetails: "",
+ extensionData: bytes("")
+ });
+ }
+
+ /// @dev Cumulative units consumed (transferred out) from the seller cert. Consumption is the only thing
+ /// that shrinks the seller cert — a partial sale decrements unitsRepresented, a full sale voids the cert —
+ /// so consumed == initial (UNITS) minus what remains. Replaces the old mock's `consumedUnits` counter.
+ function _consumed(uint256 tokenId) internal view returns (uint256) {
+ if (certPrinter.isVoided(tokenId)) return UNITS;
+ return UNITS - certPrinter.getCertificateDetails(tokenId).unitsRepresented;
+ }
+
+ /// @dev Cumulative units released back to the seller's free pool (cancel/void), for a SELL offer that
+ /// reserves the whole UNITS at postOffer: released == reserved-ever (UNITS) − consumed − still-reserved.
+ /// The real CyberCertPrinter tracks only net `unitsReserved`, so this reconstructs the old mock counter.
+ /// Not valid for bids (which reserve per-lot, not UNITS); those assert live `unitsReserved` directly.
+ function _released(uint256 tokenId) internal view returns (uint256) {
+ return UNITS - _consumed(tokenId) - certPrinter.unitsReserved(tokenId);
+ }
+
+ function _defaultSellOfferParams() internal view returns (PostOfferParams memory p) {
+ p = PostOfferParams({
+ side: OfferSide.SELL,
+ certPrinter: address(certPrinter),
+ tokenId: sellerTokenId,
+ units: UNITS,
+ paymentToken: address(paymentToken),
+ consideration: CONSIDERATION,
+ exemptionPathway: ExemptionPathway.SECTION_4A7,
+ validUntil: block.timestamp + 1 days,
+ counterpartyRestrictions: "",
+ additionalTerms: ADDITIONAL_TERMS,
+ integrator: address(0),
+ templateId: bytes32(0),
+ salt: uint256(keccak256("defaultSellOffer")),
+ globalValues: new string[](0),
+ offerorPartyValues: new string[](0),
+ offerorAgreementSig: "",
+ openEndorsementSig: OPEN_ENDORSEMENT_SIG,
+ buyerName: "",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0)
+ });
+ }
+
+ function _defaultBuyOfferParams() internal view returns (PostOfferParams memory p) {
+ p = PostOfferParams({
+ side: OfferSide.BUY,
+ certPrinter: address(certPrinter),
+ tokenId: 0,
+ units: UNITS,
+ paymentToken: address(paymentToken),
+ consideration: CONSIDERATION,
+ exemptionPathway: ExemptionPathway.SECTION_4A7,
+ validUntil: block.timestamp + 1 days,
+ counterpartyRestrictions: "",
+ additionalTerms: ADDITIONAL_TERMS,
+ integrator: address(0),
+ templateId: bytes32(0),
+ salt: uint256(keccak256("defaultBid")),
+ globalValues: new string[](0),
+ offerorPartyValues: new string[](0),
+ offerorAgreementSig: "",
+ openEndorsementSig: "",
+ buyerName: "Test Buyer",
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0)
+ });
+ }
+
+ function _postSellOffer() internal returns (bytes32 offerAgreementId) {
+ vm.prank(seller);
+ offerAgreementId = dm.postOffer(_defaultSellOfferParams());
+ }
+
+ // Creates a primary issuance deal (has a LexScrow escrow, no SecondaryEscrow). Used to verify
+ // secondary functions reject a primary id.
+ function _proposePrimaryDeal(uint256 salt) internal returns (bytes32 agreementId) {
+ CertificateDetails[] memory certDetails = new CertificateDetails[](1);
+ address[] memory parties = new address[](2);
+ parties[0] = owner;
+ parties[1] = buyer;
+ string[][] memory partyValues = new string[][](2);
+ partyValues[0] = new string[](0);
+ partyValues[1] = new string[](0);
+ address[] memory printers = new address[](1);
+ printers[0] = address(certPrinter);
+
+ vm.prank(owner);
+ (agreementId,) = dm.proposeDeal(
+ printers,
+ address(paymentToken),
+ CONSIDERATION,
+ bytes32(0),
+ salt,
+ new string[](0),
+ parties,
+ certDetails,
+ partyValues,
+ new address[](0),
+ bytes32(0),
+ block.timestamp + 1 days
+ );
+ }
+
+ function _postBid() internal returns (bytes32 offerAgreementId) {
+ vm.prank(buyer);
+ offerAgreementId = dm.postOffer(_defaultBuyOfferParams());
+ }
+
+ function _acceptSellOffer(bytes32 offerAgreementId) internal returns (bytes32 settlementAgreementId) {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerAgreementId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerAgreementId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ settlementAgreementId = dm.acceptOffer(p);
+ }
+
+ function _acceptBid(bytes32 offerAgreementId) internal returns (bytes32 settlementAgreementId) {
+ return _acceptBidPartial(offerAgreementId, UNITS);
+ }
+
+ function _acceptBidPartial(bytes32 offerAgreementId, uint256 units)
+ internal
+ returns (bytes32 settlementAgreementId)
+ {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerAgreementId,
+ units: units,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: sellerTokenId,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerAgreementId, seller, sellerKey),
+ openEndorsementSig: OPEN_ENDORSEMENT_SIG
+ });
+ vm.prank(seller);
+ settlementAgreementId = dm.acceptOffer(p);
+ }
+
+ /// @dev Assert every critical SecondaryEscrow field for a freshly ACCEPTED lot, so the stored
+ /// status enum is backed by correct custody/routing data — i.e. the escrow's true state matches
+ /// its ACCEPTED enum. tokenId is always the seller's Ledger Entry Token (sell: offer.tokenId;
+ /// buy: acceptor-supplied sellerTokenId); feeDestination is zero because no integrator is set.
+ function _assertAcceptedEscrow(
+ bytes32 settlementId,
+ bytes32 offerId,
+ address acceptor,
+ uint256 units,
+ uint256 payment
+ ) internal view {
+ SecondaryEscrow memory se = dm.getSecondaryEscrow(settlementId);
+ assertEq(uint8(se.status), uint8(SecondaryEscrowStatus.ACCEPTED), "escrow status ACCEPTED");
+ assertEq(se.counterparty, acceptor, "counterparty is the acceptor");
+ assertEq(se.offerId, offerId, "offerId back-link");
+ assertEq(se.paymentToken, address(paymentToken), "payment token");
+ assertEq(se.paymentAmount, payment, "payment amount for this lot");
+ assertEq(se.units, units, "units in this lot");
+ assertEq(se.expiry, block.timestamp + 7 days, "expiry runs the settlement window from acceptance, not offer validUntil");
+ assertEq(se.feeDestination, address(0), "no integrator set -> fees route to platform");
+ assertEq(se.tokenId, sellerTokenId, "reservation target is the seller's tokenId");
+
+ // Per-settlement materialization fields, recorded for both sides (redundant for the side that
+ // already carries the value on the Offer). Sourced per offer side:
+ // - bid: buyer info from the offer (the offeror is the buyer); endorsement from the acceptance params
+ // - sell: buyer info from the acceptance params; endorsement copied from the offer's pre-signed one
+ Offer memory o = dm.getOffer(offerId);
+ assertEq(uint8(se.buyerHostingMode), uint8(HostingMode.DIRECT), "buyerHostingMode");
+ assertEq(se.adminMultisig, address(0), "adminMultisig");
+ if (o.side == OfferSide.BUY) {
+ assertEq(se.buyerName, o.buyerName, "buyerName from the bid offer");
+ assertEq(se.openEndorsementSig, OPEN_ENDORSEMENT_SIG, "endorsement from the acceptance params");
+ } else {
+ assertEq(se.buyerName, SELL_ACCEPT_BUYER_NAME, "buyerName from the acceptance params");
+ assertEq(se.openEndorsementSig, o.openEndorsementSig, "endorsement copied from the offer");
+ }
+ }
+
+ /// @dev Asserts every field of a freshly posted Offer matches the post params and DealManager config,
+ /// so the stored record is known complete and clean.
+ function _assertPostedOffer(bytes32 offerId, PostOfferParams memory p, address offeror) internal view {
+ Offer memory offer = dm.getOffer(offerId);
+
+ // identity & classification
+ assertEq(offer.offerId, offerId, "offerId self-reference");
+ assertEq(offer.spvAddress, address(corp), "spvAddress");
+ assertEq(offer.offeror, offeror, "offeror");
+ assertEq(uint8(offer.side), uint8(p.side), "side");
+ assertEq(offer.certPrinter, p.certPrinter, "certPrinter");
+ assertEq(offer.tokenId, p.tokenId, "tokenId");
+
+ // economics
+ assertEq(offer.units, p.units, "units");
+ assertEq(offer.paymentToken, p.paymentToken, "paymentToken");
+ assertEq(offer.consideration, p.consideration, "consideration");
+ assertEq(uint8(offer.exemptionPathway), uint8(p.exemptionPathway), "exemptionPathway");
+ assertEq(offer.validUntil, p.validUntil, "validUntil");
+ address expectedIntegrator = p.integrator != address(0) ? p.integrator : dm.getDefaultIntegrator();
+ assertEq(offer.integrator, expectedIntegrator, "integrator");
+
+ // §8.1 opaque blobs
+ assertEq(offer.counterpartyRestrictions, p.counterpartyRestrictions, "counterpartyRestrictions");
+ assertEq(offer.additionalTerms, p.additionalTerms, "additionalTerms");
+
+ // fresh offer: LIVE, zeroed counters, no settlements
+ assertEq(uint8(offer.status), uint8(OfferStatus.LIVE), "status LIVE");
+ assertEq(offer.unitsAccepted, 0, "unitsAccepted");
+ assertEq(offer.paymentAccepted, 0, "paymentAccepted");
+ assertEq(offer.unitsFinalized, 0, "unitsFinalized");
+ assertEq(offer.settlementAgreementIds.length, 0, "no settlements at post");
+
+ // agreement-template fields
+ assertEq(offer.templateId, p.templateId, "templateId");
+ assertEq(offer.salt, p.salt, "salt");
+ assertEq(offer.globalValues.length, p.globalValues.length, "globalValues length");
+ for (uint256 i = 0; i < p.globalValues.length; i++) {
+ assertEq(offer.globalValues[i], p.globalValues[i], "globalValues element");
+ }
+ assertEq(offer.offerorPartyValues.length, p.offerorPartyValues.length, "offerorPartyValues length");
+ for (uint256 i = 0; i < p.offerorPartyValues.length; i++) {
+ assertEq(offer.offerorPartyValues[i], p.offerorPartyValues[i], "offerorPartyValues element");
+ }
+ assertEq(offer.offerorAgreementSig, p.offerorAgreementSig, "offerorAgreementSig");
+ assertEq(offer.openEndorsementSig, p.openEndorsementSig, "openEndorsementSig");
+
+ // buyer fields: carried on BUY, empty on SELL
+ if (p.side == OfferSide.BUY) {
+ assertEq(offer.buyerName, p.buyerName, "buyerName");
+ assertEq(uint8(offer.buyerHostingMode), uint8(p.buyerHostingMode), "buyerHostingMode");
+ assertEq(offer.adminMultisig, p.adminMultisig, "adminMultisig");
+ } else {
+ assertEq(offer.buyerName, "", "sell offers carry no buyerName");
+ assertEq(uint8(offer.buyerHostingMode), uint8(HostingMode.DIRECT), "sell offers carry no buyerHostingMode");
+ assertEq(offer.adminMultisig, address(0), "sell offers carry no adminMultisig");
+ }
+
+ // condition snapshots from DealManager config
+ address[] memory spv = dm.getSpvThresholdConditions();
+ address[] memory pathway = dm.getPathwayThresholdConditions(p.exemptionPathway);
+ assertEq(offer.thresholdConditions.length, spv.length + pathway.length, "thresholdConditions length");
+ for (uint256 i = 0; i < spv.length; i++) {
+ assertEq(offer.thresholdConditions[i], spv[i], "thresholdConditions SPV element");
+ }
+ for (uint256 i = 0; i < pathway.length; i++) {
+ assertEq(offer.thresholdConditions[spv.length + i], pathway[i], "thresholdConditions pathway element");
+ }
+ address[] memory closing = dm.getClosingConditions();
+ assertEq(offer.closingConditions.length, closing.length, "closingConditions length");
+ for (uint256 i = 0; i < closing.length; i++) {
+ assertEq(offer.closingConditions[i], closing[i], "closingConditions element");
+ }
+ }
+
+ /// @dev Asserts an offer's whole record after a state change: the mutable fields (status, the three
+ /// accounting counters, the settlement nonce) match the expected values, and its identity fields are
+ /// unchanged from the post-time baseline. Capture `baseline` right after postOffer and reuse it.
+ function _assertOfferState(
+ bytes32 offerId,
+ Offer memory baseline,
+ OfferStatus status,
+ uint256 unitsAccepted,
+ uint256 paymentAccepted,
+ uint256 unitsFinalized,
+ uint256 settlementCount
+ ) internal view {
+ Offer memory o = dm.getOffer(offerId);
+
+ // mutable fields
+ assertEq(uint8(o.status), uint8(status), "status");
+ assertEq(o.unitsAccepted, unitsAccepted, "unitsAccepted");
+ assertEq(o.paymentAccepted, paymentAccepted, "paymentAccepted");
+ assertEq(o.unitsFinalized, unitsFinalized, "unitsFinalized");
+ assertEq(o.settlementAgreementIds.length, settlementCount, "settlementAgreementIds length");
+
+ _assertOfferImmutableUnchanged(o, baseline);
+ }
+
+ /// @dev Asserts the offer's identity fields still equal the post-time baseline `b`.
+ function _assertOfferImmutableUnchanged(Offer memory o, Offer memory b) internal pure {
+ assertEq(o.offerId, b.offerId, "offerId immutable");
+ assertEq(o.spvAddress, b.spvAddress, "spvAddress immutable");
+ assertEq(o.offeror, b.offeror, "offeror immutable");
+ assertEq(uint8(o.side), uint8(b.side), "side immutable");
+ assertEq(o.certPrinter, b.certPrinter, "certPrinter immutable");
+ assertEq(o.tokenId, b.tokenId, "tokenId immutable");
+ assertEq(o.units, b.units, "units immutable");
+ assertEq(o.paymentToken, b.paymentToken, "paymentToken immutable");
+ assertEq(o.consideration, b.consideration, "consideration immutable");
+ assertEq(uint8(o.exemptionPathway), uint8(b.exemptionPathway), "exemptionPathway immutable");
+ assertEq(o.validUntil, b.validUntil, "validUntil immutable");
+ assertEq(o.integrator, b.integrator, "integrator immutable");
+ assertEq(o.counterpartyRestrictions, b.counterpartyRestrictions, "counterpartyRestrictions immutable");
+ assertEq(o.additionalTerms, b.additionalTerms, "additionalTerms immutable");
+ assertEq(o.templateId, b.templateId, "templateId immutable");
+ assertEq(o.salt, b.salt, "salt immutable");
+ assertEq(o.offerorAgreementSig, b.offerorAgreementSig, "offerorAgreementSig immutable");
+ assertEq(o.openEndorsementSig, b.openEndorsementSig, "openEndorsementSig immutable");
+ // Buyer fields stay as posted on the Offer for both sides; the per-settlement buyer info and the
+ // endorsement actually used live on each SecondaryEscrow, not here.
+ assertEq(o.buyerName, b.buyerName, "buyerName immutable");
+ assertEq(uint8(o.buyerHostingMode), uint8(b.buyerHostingMode), "buyerHostingMode immutable");
+ assertEq(o.adminMultisig, b.adminMultisig, "adminMultisig immutable");
+ assertEq(o.globalValues.length, b.globalValues.length, "globalValues length immutable");
+ for (uint256 i = 0; i < b.globalValues.length; i++) {
+ assertEq(o.globalValues[i], b.globalValues[i], "globalValues element immutable");
+ }
+ assertEq(o.offerorPartyValues.length, b.offerorPartyValues.length, "offerorPartyValues length immutable");
+ for (uint256 i = 0; i < b.offerorPartyValues.length; i++) {
+ assertEq(o.offerorPartyValues[i], b.offerorPartyValues[i], "offerorPartyValues element immutable");
+ }
+ assertEq(o.thresholdConditions.length, b.thresholdConditions.length, "thresholdConditions length immutable");
+ for (uint256 i = 0; i < b.thresholdConditions.length; i++) {
+ assertEq(o.thresholdConditions[i], b.thresholdConditions[i], "thresholdConditions element immutable");
+ }
+ assertEq(o.closingConditions.length, b.closingConditions.length, "closingConditions length immutable");
+ for (uint256 i = 0; i < b.closingConditions.length; i++) {
+ assertEq(o.closingConditions[i], b.closingConditions[i], "closingConditions element immutable");
+ }
+ }
+
+ /// @dev Both parties of a settlement are always {seller, buyer} regardless of offer side. A
+ /// settlement only reaches VOIDED once both have requested the void (registry quorum), so the
+ /// escrow flips on the second call.
+ function _voidSettlementBothParties(bytes32 settlementId) internal {
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+ vm.prank(seller);
+ dm.voidSecondaryTradeAgreement(settlementId, seller, "");
+ }
+
+ /// @dev EIP-712 agreement signature over a contractId for a signer with the given party values.
+ /// All offers/deals here use the empty-field template, so global/party fields and global values are empty.
+ function _agreementSig(bytes32 agreementId, string[] memory partyValues, uint256 key)
+ internal
+ view
+ returns (bytes memory)
+ {
+ return CyberAgreementUtils.signAgreementTypedData(
+ vm,
+ registry.DOMAIN_SEPARATOR(),
+ registry.SIGNATUREDATA_TYPEHASH(),
+ agreementId,
+ TEMPLATE_URI,
+ new string[](0), // globalFields
+ new string[](0), // partyFields
+ new string[](0), // globalValues
+ partyValues,
+ key
+ );
+ }
+
+ /// @dev Recomputes the next settlement agreement id for an offer and returns the acceptor's
+ /// EIP-712 signature over it (the real registry verifies the acceptor via signContractFor).
+ function _acceptorSig(bytes32 offerId, address acceptor, uint256 key) internal view returns (bytes memory) {
+ Offer memory o = dm.getOffer(offerId);
+ bytes32 settlementSalt = keccak256(abi.encodePacked(o.salt, o.settlementAgreementIds.length));
+ address[] memory parties = new address[](2);
+ parties[0] = o.offeror;
+ parties[1] = acceptor;
+ bytes32 settlementId = keccak256(abi.encode(o.templateId, uint256(settlementSalt), o.globalValues, parties));
+ return _agreementSig(settlementId, new string[](0), key);
+ }
+
+ /// @dev EIP-712 void signature over a contractId (needed only for direct registry void calls,
+ /// i.e. not routed through DealManager, which is the finalizer and skips verification).
+ function _voidSig(bytes32 agreementId, address party, uint256 key) internal view returns (bytes memory) {
+ return CyberAgreementUtils.signVoidAgreementTypedData(
+ vm, registry.DOMAIN_SEPARATOR(), registry.VOIDSIGNATUREDATA_TYPEHASH(), agreementId, party, key
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // postOffer — sell
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_PostOffer_Sell() public {
+ // ∅ → LIVE: stores the offer record, reserves the offered units, and emits OfferPosted.
+ vm.expectEmit(false, true, true, true); // skip offerId (computed inside); check offeror, certPrinter, and all data
+ emit ISecondaryTradeStorage.OfferPosted(
+ bytes32(0),
+ seller,
+ address(certPrinter),
+ address(corp),
+ OfferSide.SELL,
+ sellerTokenId,
+ UNITS,
+ address(paymentToken),
+ CONSIDERATION,
+ ExemptionPathway.SECTION_4A7,
+ block.timestamp + 1 days,
+ address(0),
+ bytes32(0),
+ "",
+ HostingMode.DIRECT,
+ address(0),
+ "",
+ new address[](0),
+ new address[](0)
+ );
+ bytes32 offerId = _postSellOffer();
+
+ // Assert every Offer field is stored clean (see _assertPostedOffer).
+ _assertPostedOffer(offerId, _defaultSellOfferParams(), seller);
+
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "units should be reserved");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // postOffer — bid
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_PostOffer_Buy() public {
+ // ∅ → LIVE: stores the offer record (no units reserved) and pulls consideration into holding escrow.
+ uint256 buyerBalanceBefore = paymentToken.balanceOf(buyer);
+ vm.expectEmit(false, true, true, true); // skip offerId; check offeror, certPrinter, and all data
+ emit ISecondaryTradeStorage.OfferPosted(
+ bytes32(0),
+ buyer,
+ address(certPrinter),
+ address(corp),
+ OfferSide.BUY,
+ 0,
+ UNITS,
+ address(paymentToken),
+ CONSIDERATION,
+ ExemptionPathway.SECTION_4A7,
+ block.timestamp + 1 days,
+ address(0),
+ bytes32(0),
+ "Test Buyer",
+ HostingMode.DIRECT,
+ address(0),
+ "",
+ new address[](0),
+ new address[](0)
+ );
+ bytes32 offerId = _postBid();
+
+ // Assert every Offer field is stored clean (see _assertPostedOffer).
+ _assertPostedOffer(offerId, _defaultBuyOfferParams(), buyer);
+
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "bid should reserve no units at post");
+
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBalanceBefore - CONSIDERATION,
+ "buyer consideration should be in holding escrow"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "funds should be in DealManager");
+ }
+
+ function test_RevertIf_PostOffer_MissingCertPrinter_Sell() public {
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.certPrinter = address(0);
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.MissingCertPrinter.selector);
+ dm.postOffer(p);
+ }
+
+ function test_RevertIf_PostOffer_MissingCertPrinter_Bid() public {
+ PostOfferParams memory p = _defaultBuyOfferParams();
+ p.certPrinter = address(0);
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.MissingCertPrinter.selector);
+ dm.postOffer(p);
+ }
+
+ // An offer cannot point at a printer this SPV's IssuanceManager did not create: without the check a buyer
+ // could pay real tokens for a cert minted on a fake/foreign printer (0xBEEF is not in the registry).
+ function test_RevertIf_PostOffer_Sell_UnknownCertPrinter() public {
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.certPrinter = address(0xBEEF);
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.UnknownCertPrinter.selector);
+ dm.postOffer(p);
+ }
+
+ function test_RevertIf_PostOffer_Buy_UnknownCertPrinter() public {
+ PostOfferParams memory p = _defaultBuyOfferParams();
+ p.certPrinter = address(0xBEEF);
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.UnknownCertPrinter.selector);
+ dm.postOffer(p);
+ }
+
+ // A non-owner cannot list someone else's Ledger Entry Token for sale: without the ownership guard the
+ // attacker would be paid at finalize while the real owner's cert is decremented (buyer does not own
+ // sellerTokenId, which belongs to `seller`).
+ function test_RevertIf_PostOffer_Sell_NotCertOwner() public {
+ PostOfferParams memory p = _defaultSellOfferParams();
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.NotCertOwner.selector);
+ dm.postOffer(p);
+ }
+
+ // BUY counterpart: a non-owner acceptor cannot sell someone else's Ledger Entry Token into a bid
+ // (attacker supplies sellerTokenId, owned by `seller`).
+ function test_RevertIf_AcceptBid_NotCertOwner() public {
+ (address attacker, uint256 attackerKey) = makeAddrAndKey("attacker");
+ bytes32 offerId = _postBid();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: sellerTokenId,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, attacker, attackerKey),
+ openEndorsementSig: OPEN_ENDORSEMENT_SIG
+ });
+ vm.prank(attacker);
+ vm.expectRevert(ISecondaryTradeStorage.NotCertOwner.selector);
+ dm.acceptOffer(p);
+ }
+
+ // Posting a SELL offer reserves (escrows) the seller's units, freezing legal ownership: an attempt to
+ // re-register the cert to a new legal owner while the offer is live reverts at the source.
+ function test_RevertIf_AssignReservedCert_AfterPostSellOffer() public {
+ address newOwner = makeAddr("newLegalOwner");
+ _postSellOffer();
+
+ vm.prank(owner);
+ vm.expectRevert(CyberCertPrinter.CertificateReserved.selector);
+ im.assignCert(address(certPrinter), seller, sellerTokenId, newOwner, _sellerCertDetails(UNITS));
+
+ // Below verifies DealManager does double-check the legal ownership at every step, but we will never reach there if
+ // the cert's reserve rule works as expected
+// AcceptOfferParams memory p = AcceptOfferParams({
+// offerId: offerId,
+// units: UNITS,
+// buyerName: SELL_ACCEPT_BUYER_NAME,
+// buyerHostingMode: HostingMode.DIRECT,
+// adminMultisig: address(0),
+// sellerTokenId: 0,
+// acceptorPartyValues: new string[](0),
+// acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+// openEndorsementSig: ""
+// });
+// vm.prank(buyer);
+// vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeSellerOwnershipChanged.selector);
+// dm.acceptOffer(p);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // threshold conditions in an Offer
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Conditions are owner-managed DealManager config (snapshotted onto the offer at postOffer), not
+ // offeror-supplied. Register them as fund-specific (Layer 2 / per-SPV) conditions so they apply to
+ // every one of the test's offers regardless of exemption pathway.
+ function _registerThresholdConditions(address[] memory conds) internal {
+ for (uint256 i = 0; i < conds.length; i++) {
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(conds[i]);
+ }
+ }
+
+ function test_PostOffer_Sell_MultipleThresholdConditionsAllPass() public {
+ address[] memory conds = new address[](2);
+ conds[0] = address(new SecConditionMock(true));
+ conds[1] = address(new SecConditionMock(true));
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_PostOffer_Sell_MultipleThresholdConditionsAllPass"));
+ _registerThresholdConditions(conds);
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+ assertTrue(offerId != bytes32(0));
+ }
+
+ function test_RevertIf_PostOffer_FirstThresholdConditionFails() public {
+ address[] memory conds = new address[](2);
+ conds[0] = address(new SecConditionMock(false));
+ conds[1] = address(new SecConditionMock(true));
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_RevertIf_PostOffer_FirstThresholdConditionFails"));
+ _registerThresholdConditions(conds);
+
+ vm.prank(seller);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, conds[0]));
+ dm.postOffer(p);
+ }
+
+ function test_RevertIf_PostOffer_SecondThresholdConditionFails() public {
+ address[] memory conds = new address[](2);
+ conds[0] = address(new SecConditionMock(true));
+ conds[1] = address(new SecConditionMock(false));
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_RevertIf_PostOffer_SecondThresholdConditionFails"));
+ _registerThresholdConditions(conds);
+
+ vm.prank(seller);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, conds[1]));
+ dm.postOffer(p);
+ }
+
+ function test_PostOffer_OfferHasThresholdConditions() public {
+ address[] memory conds = new address[](1);
+ conds[0] = address(new SecOfferReadingConditionMock());
+ _registerThresholdConditions(conds);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_PostOffer_OfferHasThresholdConditions"));
+
+ vm.prank(seller);
+ // SecOfferReadingConditionMock.checkCondition() would fail if `Offer` is not updated
+ bytes32 offerId = dm.postOffer(p);
+
+ Offer memory offer = dm.getOffer(offerId);
+ assertEq(offer.offeror, seller, "offeror must be readable by conditions");
+ assertEq(offer.certPrinter, address(certPrinter), "certPrinter must be readable by conditions");
+ assertEq(offer.tokenId, sellerTokenId, "tokenId must be readable by conditions");
+ }
+
+ function test_RevertIf_AcceptOffer_BuyerFacingThresholdConditionFails() public {
+ address[] memory conds = new address[](1);
+ conds[0] = address(new SecBuyerFacingConditionMock(false)); // posting passes, acceptance fails
+ _registerThresholdConditions(conds);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_RevertIf_AcceptOffer_BuyerFacingThresholdConditionFails"));
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p); // posting succeeds: condition short-circuits with no acceptor
+
+ AcceptOfferParams memory ap = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, conds[0]));
+ dm.acceptOffer(ap);
+ }
+
+ // Counterpart to the above: when the acceptor satisfies the buyer-facing condition, the re-evaluation
+ // passes and acceptance proceeds — proving the re-check runs and is not an unconditional block.
+ function test_AcceptOffer_BuyerFacingThresholdConditionPasses() public {
+ address[] memory conds = new address[](1);
+ conds[0] = address(new SecBuyerFacingConditionMock(true)); // passes at posting and acceptance
+ _registerThresholdConditions(conds);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_AcceptOffer_BuyerFacingThresholdConditionPasses"));
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ assertTrue(settlementId != bytes32(0), "acceptance should succeed when the acceptor passes conditions");
+ }
+
+ // counterpartyRestrictions (spec §8.1): an acceptor-side threshold condition reads the offer's blob and
+ // gates acceptance on it. Posting short-circuits (no acceptor yet), so the check bites at acceptOffer.
+ function test_AcceptOffer_CounterpartyRestrictionsConditionPasses() public {
+ address[] memory conds = new address[](1);
+ conds[0] = address(new SecCounterpartyRestrictionsConditionMock(COUNTERPARTY_RESTRICTIONS));
+ _registerThresholdConditions(conds);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_AcceptOffer_CounterpartyRestrictionsConditionPasses"));
+ p.counterpartyRestrictions = COUNTERPARTY_RESTRICTIONS; // matches the condition's expected blob
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ assertTrue(settlementId != bytes32(0), "acceptance should succeed when restrictions match");
+ }
+
+ // Counterpart: when the offer's blob does not match what the condition requires, posting still succeeds
+ // (the gate short-circuits with no acceptor) but acceptance reverts — the spec's acceptance-time failure.
+ function test_RevertIf_AcceptOffer_CounterpartyRestrictionsConditionFails() public {
+ address[] memory conds = new address[](1);
+ conds[0] = address(new SecCounterpartyRestrictionsConditionMock(COUNTERPARTY_RESTRICTIONS));
+ _registerThresholdConditions(conds);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_RevertIf_AcceptOffer_CounterpartyRestrictionsConditionFails"));
+ // counterpartyRestrictions left empty -> mismatch against the condition's expected blob
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p); // posting succeeds: condition short-circuits with no acceptor
+
+ AcceptOfferParams memory ap = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, conds[0]));
+ dm.acceptOffer(ap);
+ }
+
+ function test_ThresholdConditions_PathwayConditionAppliesToMatchingPathway() public {
+ address succeeding = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, succeeding);
+
+ // RULE_144 offer: should have the L3 condition
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_Config_Pathway_144"));
+ p.exemptionPathway = ExemptionPathway.RULE_144;
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+ assertEq(
+ dm.getOffer(offerId).thresholdConditions.length, 1, "pathway condition should be applied to Rule 144 offer"
+ );
+ }
+
+ function test_ThresholdConditions_PathwayConditionNotAppliesToOtherPathway() public {
+ address failing = address(new SecConditionMock(false));
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, failing);
+
+ // SECTION_4A7 offer: the RULE_144 condition is not in its resolved set, so posting succeeds.
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_Config_Pathway_4a7"));
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+ assertEq(dm.getOffer(offerId).thresholdConditions.length, 0, "no pathway condition applied to 4(a)(7) offer");
+ }
+
+ function test_RevertIf_ThresholdConditions_PathwayConditionFailsForMatchingPathway() public {
+ address failing = address(new SecConditionMock(false));
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, failing);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_Config_Pathway_144"));
+ p.exemptionPathway = ExemptionPathway.RULE_144;
+ vm.prank(seller);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, failing));
+ dm.postOffer(p);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // threshold condition configurations
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // The two §7.2 threshold layers — fund-specific (Layer 2 / per-SPV) ++ exemption-specific (Layer 1 /
+ // per-pathway) — are concatenated in order and snapshotted onto the offer at postOffer; the offeror
+ // supplies only the pathway, never the addresses.
+ function test_Config_ResolvesSpvAndPathwayOntoOffer() public {
+ address spv = address(new SecConditionMock(true));
+ address pathway = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(spv);
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.SECTION_4A7, pathway);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_Config_ResolvesSpvAndPathwayOntoOffer"));
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ address[] memory resolved = dm.getOffer(offerId).thresholdConditions;
+ assertEq(resolved.length, 2, "fund-specific + exemption-specific both resolved");
+ assertEq(resolved[0], spv, "fund-specific (Layer 2) first");
+ assertEq(resolved[1], pathway, "exemption-specific (Layer 1) last");
+ }
+
+ function test_RevertIf_Config_AddSpvZeroAddressCondition() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryCondition.selector);
+ dm.addSpvThresholdCondition(address(0));
+ }
+
+ function test_RevertIf_Config_AddPathwayZeroAddressCondition() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryCondition.selector);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, address(0));
+ }
+
+ // Closing-set zero-address rejection (same guarded add path as the threshold layers).
+ function test_RevertIf_Config_AddClosingZeroAddressCondition() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryCondition.selector);
+ dm.addClosingCondition(address(0));
+ }
+
+ // Interface rejection — a condition that doesn't advertise ISecondaryTradingCondition via ERC-165 is
+ // rejected at config time (shared _addCondition guard), per threshold layer and the closing set.
+ function test_RevertIf_Config_AddSpvUnsupportedInterfaceCondition() public {
+ address c = address(new SecNonConditionMock());
+ vm.prank(owner);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionInterfaceUnsupported.selector, c));
+ dm.addSpvThresholdCondition(c);
+ }
+
+ function test_RevertIf_Config_AddPathwayUnsupportedInterfaceCondition() public {
+ address c = address(new SecNonConditionMock());
+ vm.prank(owner);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionInterfaceUnsupported.selector, c));
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, c);
+ }
+
+ function test_RevertIf_Config_AddClosingUnsupportedInterfaceCondition() public {
+ address c = address(new SecNonConditionMock());
+ vm.prank(owner);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionInterfaceUnsupported.selector, c));
+ dm.addClosingCondition(c);
+ }
+
+ // Duplicate rejection — per threshold layer (fund-specific / exemption-specific).
+ function test_RevertIf_Config_AddSpvDuplicateCondition() public {
+ address c = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(c);
+
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionAlreadyExists.selector);
+ dm.addSpvThresholdCondition(c);
+ }
+
+ function test_RevertIf_Config_AddPathwayDuplicateCondition() public {
+ address c = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, c);
+
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionAlreadyExists.selector);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, c);
+ }
+
+ // Closing-set duplicate rejection (same guarded add path as the threshold layers).
+ function test_RevertIf_Config_AddClosingDuplicateCondition() public {
+ address c = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addClosingCondition(c);
+
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionAlreadyExists.selector);
+ dm.addClosingCondition(c);
+ }
+
+ // Out-of-bounds removal reverts — per threshold layer (fund-specific / exemption-specific).
+ function test_RevertIf_Config_RemoveSpvIndexOutOfBounds() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionIndexOutOfBounds.selector);
+ dm.removeSpvThresholdConditionAt(0);
+ }
+
+ function test_RevertIf_Config_RemovePathwayIndexOutOfBounds() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionIndexOutOfBounds.selector);
+ dm.removePathwayThresholdConditionAt(ExemptionPathway.RULE_144, 0);
+ }
+
+ function test_RevertIf_Config_RemoveClosingIndexOutOfBounds() public {
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryConditionIndexOutOfBounds.selector);
+ dm.removeClosingConditionAt(0);
+ }
+
+ // Swap-pop removal (Layer 2 fund-specific / per-SPV): removing index 0 of [a,b] leaves [b].
+ function test_Config_RemoveSpvConditionSwapPop() public {
+ address a = address(new SecConditionMock(true));
+ address b = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(a);
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(b);
+
+ vm.prank(owner);
+ dm.removeSpvThresholdConditionAt(0);
+
+ address[] memory remaining = dm.getSpvThresholdConditions();
+ assertEq(remaining.length, 1, "one condition remains");
+ assertEq(remaining[0], b, "swap-pop moved the last element into the hole");
+ }
+
+ // Swap-pop removal (Layer 1 exemption-specific / per-pathway): removing index 0 of [a,b] leaves [b]; keyed by pathway.
+ function test_Config_RemovePathwayConditionSwapPop() public {
+ address a = address(new SecConditionMock(true));
+ address b = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, a);
+ vm.prank(owner);
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, b);
+
+ vm.prank(owner);
+ dm.removePathwayThresholdConditionAt(ExemptionPathway.RULE_144, 0);
+
+ address[] memory remaining = dm.getPathwayThresholdConditions(ExemptionPathway.RULE_144);
+ assertEq(remaining.length, 1, "one condition remains");
+ assertEq(remaining[0], b, "swap-pop moved the last element into the hole");
+ }
+
+ // Swap-pop removal (closing set): removing index 0 of [a,b] leaves [b].
+ function test_Config_RemoveClosingConditionSwapPop() public {
+ address a = address(new SecConditionMock(true));
+ address b = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addClosingCondition(a);
+ vm.prank(owner);
+ dm.addClosingCondition(b);
+
+ vm.prank(owner);
+ dm.removeClosingConditionAt(0);
+
+ address[] memory remaining = dm.getClosingConditions();
+ assertEq(remaining.length, 1, "one condition remains");
+ assertEq(remaining[0], b, "swap-pop moved the last element into the hole");
+ }
+
+ function test_Config_GetMinTradeThreshold() public {
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS, CONSIDERATION);
+
+ (uint256 units, uint256 consideration) = dm.getMinTradeThreshold();
+ assertEq(units, UNITS, "min trade units");
+ assertEq(consideration, CONSIDERATION, "min trade consideration");
+ }
+
+ function test_Config_GetDefaultIntegrator() public {
+ address integrator = makeAddr("defaultIntegrator");
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 0);
+
+ vm.prank(owner);
+ dm.setDefaultIntegrator(integrator);
+
+ assertEq(dm.getDefaultIntegrator(), integrator, "default integrator");
+ }
+
+ function test_RevertIf_SetDefaultIntegrator_NotWhitelisted() public {
+ // Integrator never whitelisted on the factory (default mapping is false).
+ vm.prank(owner);
+ vm.expectRevert(ISecondaryTradeStorage.IntegratorNotWhitelisted.selector);
+ dm.setDefaultIntegrator(makeAddr("integrator"));
+ }
+
+ // Every onlyAdmin secondary-trade config function must reject a non-admin caller.
+ function test_RevertIf_ConfigByNonAdmin() public {
+ address c = address(new SecConditionMock(true));
+ vm.startPrank(makeAddr("stranger"));
+
+ vm.expectRevert();
+ dm.setMinTradeThreshold(1, 1);
+
+ vm.expectRevert();
+ dm.setSettlementWindow(7 days);
+
+ vm.expectRevert();
+ dm.setDefaultIntegrator(address(0));
+
+ vm.expectRevert();
+ dm.addSpvThresholdCondition(c);
+
+ vm.expectRevert();
+ dm.removeSpvThresholdConditionAt(0);
+
+ vm.expectRevert();
+ dm.addPathwayThresholdCondition(ExemptionPathway.RULE_144, c);
+
+ vm.expectRevert();
+ dm.removePathwayThresholdConditionAt(ExemptionPathway.RULE_144, 0);
+
+ vm.expectRevert();
+ dm.addClosingCondition(c);
+
+ vm.expectRevert();
+ dm.removeClosingConditionAt(0);
+
+ vm.stopPrank();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // postOffer — integrator whitelist
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_RevertIf_PostOffer_IntegratorNotWhitelisted() public {
+ // Integrator never whitelisted on the factory (default mapping is false).
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_RevertIf_PostOffer_IntegratorNotWhitelisted"));
+ p.integrator = makeAddr("integrator");
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.IntegratorNotWhitelisted.selector);
+ dm.postOffer(p);
+ }
+
+ function test_PostOffer_WhitelistedIntegratorPasses() public {
+ address integrator = makeAddr("integrator");
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 0);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_PostOffer_WhitelistedIntegratorPasses"));
+ p.integrator = integrator;
+
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+ assertTrue(offerId != bytes32(0));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // postOffer - Min trade thresholds
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_RevertIf_PostOffer_BelowMinUnits() public {
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS + 1, 0);
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.postOffer(_defaultSellOfferParams()); // offers exactly UNITS
+ }
+
+ function test_RevertIf_PostOffer_BelowMinConsideration() public {
+ vm.prank(owner);
+ dm.setMinTradeThreshold(0, CONSIDERATION + 1);
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.postOffer(_defaultSellOfferParams()); // offers exactly CONSIDERATION
+ }
+
+ function test_RevertIf_PostOffer_ZeroUnitsWithFloorsDisabled() public {
+ // Floors left disabled (never set), so the min-threshold check is a no-op. A zero-unit offer
+ // must still revert — otherwise it would mint an empty, un-acceptable offer.
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.units = 0;
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.postOffer(p);
+ }
+
+ function test_PostOffer_PassesAtMinThreshold() public {
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS, CONSIDERATION);
+
+ {
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(_defaultSellOfferParams()); // exactly at threshold
+ assertTrue(offerId != bytes32(0));
+ }
+
+ {
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(_defaultBuyOfferParams()); // exactly at threshold
+ assertTrue(offerId != bytes32(0));
+ }
+ }
+
+ function test_RevertIf_AcceptOffer_PartialFillBelowMinUnit() public {
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS, 0); // require full fill
+
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS - 1, // partial fill, below min
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_PartialFillBelowMinConsideration() public {
+ // Units floor disabled, but a half fill's pro-rata consideration (CONSIDERATION/2)
+ // is below the admin-set minimum ticket value — acceptance must revert.
+ vm.prank(owner);
+ dm.setMinTradeThreshold(0, CONSIDERATION / 2 + 1);
+
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS / 2, // pro-rata consideration = CONSIDERATION / 2, below min
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_ZeroUnitsWithFloorsDisabled() public {
+ // Floors left disabled (never set), so the min-threshold check is a no-op. A zero-unit fill
+ // must still revert — otherwise it would mint an empty settlement.
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: 0,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_PartialFillLeavesSubFloorRemainderUnits() public {
+ // Floors below the full offer (postOffer passes) but the fill would leave a units remainder below
+ // the floor. Rejecting it keeps the offer's tail above the floor, so no exhausting-lot exemption
+ // is ever needed.
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS / 4, CONSIDERATION / 4); // 25 units / 2.5 ether
+
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: (UNITS * 9) / 10, // 90 units → 10-unit remainder, below the 25-unit floor
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_PartialFillLeavesSubFloorRemainderConsideration() public {
+ // Units floor disabled; the fill clears its own consideration floor but would leave a consideration
+ // remainder below it. The remainder check must reject it.
+ vm.prank(owner);
+ dm.setMinTradeThreshold(0, (CONSIDERATION * 3) / 10); // 3 ether floor
+
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: (UNITS * 8) / 10, // 80 units → pro-rata 8 ether (ok), remainder 2 ether < 3 ether floor
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.BelowMinTradeThreshold.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_AcceptOffer_ExhaustingFinalLotNeedsNoExemption() public {
+ // With the remainder kept above the floor on every partial fill, the lot that finally exhausts the
+ // offer is provably above the floor and settles with no exemption. Splitting 100 as 75 + 25 (vs the
+ // old 90 + sub-floor 10) keeps both lots — and the remainder between them — above the floor.
+ vm.prank(owner);
+ dm.setMinTradeThreshold(UNITS / 4, CONSIDERATION / 4); // 25 units / 2.5 ether
+
+ bytes32 offerId = _postSellOffer();
+
+ // First fill: 75 units / 7.5 ether, leaving a 25-unit / 2.5-ether remainder (exactly at the floor).
+ _acceptSellOfferPartial(offerId, (UNITS * 3) / 4);
+
+ // Final lot exhausts the offer; no remainder check runs on it.
+ bytes32 settlementId = _acceptSellOfferPartial(offerId, UNITS - (UNITS * 3) / 4);
+
+ Offer memory offer = dm.getOffer(offerId);
+ assertEq(uint8(offer.status), uint8(OfferStatus.FULLY_ACCEPTED), "final lot should fully accept the offer");
+ assertEq(offer.unitsAccepted, UNITS);
+ assertEq(
+ dm.getSecondaryEscrow(settlementId).paymentAmount,
+ CONSIDERATION - (CONSIDERATION * 3) / 4,
+ "final lot settles the leftover consideration"
+ );
+ }
+
+ function test_AcceptOffer_PartialFill_NoThresholds_AllowsTinyLotAndRemainder() public {
+ // Floors left disabled (never set): partial fills of any size are allowed, including a tiny lot that
+ // leaves a large remainder and a tail fill that leaves a single-unit remainder. Neither the accepted
+ // lot nor the remainder is floored, so the only guard is the zero-unit reject.
+ bytes32 offerId = _postSellOffer();
+
+ // Tiny first lot leaves a 99-unit remainder.
+ _acceptSellOfferPartial(offerId, 1);
+ // Next lot leaves a single-unit remainder.
+ _acceptSellOfferPartial(offerId, UNITS - 2);
+ // Exhaust the single-unit tail.
+ bytes32 settlementId = _acceptSellOfferPartial(offerId, 1);
+
+ Offer memory offer = dm.getOffer(offerId);
+ assertEq(uint8(offer.status), uint8(OfferStatus.FULLY_ACCEPTED), "tiny fills should fully accept with no floors");
+ assertEq(offer.unitsAccepted, UNITS);
+ assertEq(
+ dm.getSecondaryEscrow(settlementId).paymentAmount,
+ CONSIDERATION - (CONSIDERATION * 1) / UNITS - (CONSIDERATION * (UNITS - 2)) / UNITS,
+ "final lot takes the leftover consideration"
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // cancelOffer — sell
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_CancelOffer_Sell_ReleasesReservation() public {
+ bytes32 offerId = _postSellOffer();
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "reservation should be released");
+ assertEq(_released(sellerTokenId), UNITS, "all units should be marked released");
+ }
+
+ function test_CancelOffer_Sell_AfterExpiry_ReleasesReservation() public {
+ bytes32 offerId = _postSellOffer();
+
+ // Expiry only blocks acceptOffer; the offeror can still cancel and reclaim the free pool.
+ vm.warp(block.timestamp + 2 days);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "reservation should be released");
+ assertEq(_released(sellerTokenId), UNITS, "all units should be marked released");
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED));
+ }
+
+ function test_CancelOffer_Sell_Uncommited() public {
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // LIVE → CANCELLED with nothing committed
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, 0, 0, 0, 0);
+ }
+
+ function test_RevertIf_CancelOffer_NotOfferor() public {
+ bytes32 offerId = _postSellOffer();
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.NotOfferor.selector);
+ dm.cancelOffer(offerId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // cancelOffer — bid
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_CancelOffer_Bid_RefundsHoldingEscrow() public {
+ bytes32 offerId = _postBid();
+ uint256 buyerBalanceBefore = paymentToken.balanceOf(buyer);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ assertEq(paymentToken.balanceOf(buyer), buyerBalanceBefore + CONSIDERATION, "buyer should be refunded");
+ }
+
+ function test_CancelOffer_Bid_AfterExpiry_RefundsHoldingEscrow() public {
+ bytes32 offerId = _postBid();
+ uint256 buyerBalanceBefore = paymentToken.balanceOf(buyer);
+
+ // Expiry only blocks acceptOffer; the offeror can still cancel and reclaim the free pool.
+ vm.warp(block.timestamp + 2 days);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ assertEq(paymentToken.balanceOf(buyer), buyerBalanceBefore + CONSIDERATION, "buyer should be refunded");
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED));
+ }
+
+ function test_CancelOffer_Buy_Uncommited() public {
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // LIVE → CANCELLED with nothing committed
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, 0, 0, 0, 0);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // cancelOffer — outstanding settlements stay active
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_CancelOffer_Sell_PartiallyFilled() public {
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ uint256 lotUnits = UNITS / 2;
+ bytes32 settlementId = _acceptSellOfferPartial(offerId, lotUnits);
+ uint256 buyerBalanceAfterAccept = paymentToken.balanceOf(buyer);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // → CANCELLED with a lot outstanding: the committed lot's counters stay
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, lotUnits, CONSIDERATION * lotUnits / UNITS, 0, 1);
+
+ // Cancel returns only the free pool; the accepted lot stays ACCEPTED and resolvable.
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "outstanding settlement stays ACCEPTED after cancel"
+ );
+ assertFalse(registry.isVoided(settlementId), "cancel must not request a settlement void");
+ assertEq(paymentToken.balanceOf(buyer), buyerBalanceAfterAccept, "active lot not refunded by cancel");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), lotUnits, "committed lot stays reserved");
+ assertEq(_released(sellerTokenId), UNITS - lotUnits, "only free units released");
+ }
+
+ function test_CancelOffer_Buy_PartiallyFilled() public {
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementId = _acceptBidPartial(offerId, 40);
+ uint256 lotPayment = CONSIDERATION * 40 / UNITS;
+ uint256 buyerBalanceAfterAccept = paymentToken.balanceOf(buyer);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // → CANCELLED with a lot outstanding: the committed lot's counters stay
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, 40, lotPayment, 0, 1);
+
+ // Cancel refunds only the free pool; the accepted lot's funds stay in custody.
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "outstanding settlement stays ACCEPTED after cancel"
+ );
+ assertFalse(registry.isVoided(settlementId), "cancel must not request a settlement void");
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBalanceAfterAccept + (CONSIDERATION - lotPayment),
+ "only free pool refunded at cancel"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), lotPayment, "committed lot's funds stay in custody");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 40, "seller's lot reservation held");
+ }
+
+ function test_CancelOffer_Sell_FullyFilled() public {
+ // Fully-filled variant of the partial case: no free pool to release; the committed lot
+ // stays ACCEPTED and its units stay reserved.
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ uint256 buyerBalanceAfterAccept = paymentToken.balanceOf(buyer);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // → CANCELLED while fully accepted: the full fill's counters stay committed
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, UNITS, CONSIDERATION, 0, 1);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "outstanding settlement stays ACCEPTED after cancel"
+ );
+ assertFalse(registry.isVoided(settlementId), "cancel must not request a settlement void");
+ assertEq(paymentToken.balanceOf(buyer), buyerBalanceAfterAccept, "committed lot not refunded by cancel");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "no free units: full reservation held");
+ assertEq(_released(sellerTokenId), 0, "nothing released: offer was fully accepted");
+ }
+
+ function test_CancelOffer_Buy_FullyFilled() public {
+ // Fully-filled variant of the partial case: no free pool to refund; the committed lot's
+ // funds stay in custody.
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementId = _acceptBid(offerId);
+ uint256 buyerBalanceAfterAccept = paymentToken.balanceOf(buyer);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // → CANCELLED while fully accepted: the full fill's counters stay committed
+ _assertOfferState(offerId, baseline, OfferStatus.CANCELLED, UNITS, CONSIDERATION, 0, 1);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "outstanding settlement stays ACCEPTED after cancel"
+ );
+ assertFalse(registry.isVoided(settlementId), "cancel must not request a settlement void");
+ assertEq(paymentToken.balanceOf(buyer), buyerBalanceAfterAccept, "no free pool to refund: fully accepted");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "committed lot's funds stay in custody");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "seller's full reservation held");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // acceptOffer — sell offer
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_AcceptOffer_Sell_SingleFullyFills() public {
+ // LIVE → FULLY_ACCEPTED directly in a single fill, with no PARTIALLY_ACCEPTED step.
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 1);
+
+ // ∅ → ACCEPTED: every escrow field is populated for the full lot.
+ _assertAcceptedEscrow(settlementId, offerId, buyer, UNITS, CONSIDERATION);
+
+ // True state behind the enum: the buyer's funds are pulled into custody and the seller's
+ // units stay reserved (reservation is taken at postOffer for sell offers).
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore - CONSIDERATION, "buyer funds pulled");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "consideration held in escrow");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "seller units reserved");
+
+ // Sell: the seller's pre-signed open-endorsement signature is captured on the escrow (asserted by
+ // _assertAcceptedEscrow above), not written to the token until secondaryTransfer materializes it at finalize.
+ }
+
+ function test_AcceptOffer_Sell_MultipleFills() public {
+ // Walks the full LIVE → PARTIALLY_ACCEPTED → FULLY_ACCEPTED lifecycle in two fills.
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ uint256 firstUnits = UNITS / 2;
+ uint256 secondUnits = UNITS - firstUnits;
+ uint256 expectedFirst = CONSIDERATION * firstUnits / UNITS;
+ uint256 expectedSecond = CONSIDERATION * secondUnits / UNITS;
+ uint256 buyerStart = paymentToken.balanceOf(buyer);
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: firstUnits,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ bytes32 settlementId1 = dm.acceptOffer(p);
+
+ // LIVE → PARTIALLY_ACCEPTED on the first fill
+ _assertOfferState(offerId, baseline, OfferStatus.PARTIALLY_ACCEPTED, firstUnits, expectedFirst, 0, 1);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "partial fill keeps the offer's full reservation");
+ _assertAcceptedEscrow(settlementId1, offerId, buyer, firstUnits, expectedFirst);
+ assertEq(
+ paymentToken.balanceOf(buyer), buyerStart - expectedFirst, "buyer pays pro-rata for the first lot only"
+ );
+
+ p.units = secondUnits;
+ p.acceptorAgreementSig = _acceptorSig(offerId, buyer, buyerKey); // settlement id changes with each fill
+ vm.prank(buyer);
+ bytes32 settlementId2 = dm.acceptOffer(p);
+
+ // PARTIALLY_ACCEPTED → FULLY_ACCEPTED: each fill is its own pro-rata settlement
+ assertTrue(settlementId1 != settlementId2, "each fill gets its own settlement escrow");
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 2);
+ // Each lot's escrow is independent and immutable: the second fill leaves the first untouched.
+ _assertAcceptedEscrow(settlementId1, offerId, buyer, firstUnits, expectedFirst);
+ _assertAcceptedEscrow(settlementId2, offerId, buyer, secondUnits, expectedSecond);
+ assertEq(paymentToken.balanceOf(buyer), buyerStart - CONSIDERATION, "buyer has now paid the full consideration");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "both lots held in custody");
+ }
+
+ // TODO WIP: should test buy-offer, too
+ function test_AcceptOffer_Sell_FinalLotTakesRoundingRemainder() public {
+ // 3 units for 100 tokens: 100 * 1 / 3 floors to 33, so three 1-unit fills would pay
+ // 33 + 33 + 33 = 99 and strand 1 token. The final lot must take the leftover consideration.
+ PostOfferParams memory params = _defaultSellOfferParams();
+ params.units = 3;
+ params.consideration = 100;
+ params.salt = uint256(keccak256("roundingRemainderOffer"));
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(params);
+
+ bytes32 s1 = _acceptSellOfferPartial(offerId, 1);
+ bytes32 s2 = _acceptSellOfferPartial(offerId, 1);
+ bytes32 s3 = _acceptSellOfferPartial(offerId, 1);
+
+ assertEq(dm.getSecondaryEscrow(s1).paymentAmount, 33, "first fill floored pro-rata");
+ assertEq(dm.getSecondaryEscrow(s2).paymentAmount, 33, "second fill floored pro-rata");
+ assertEq(dm.getSecondaryEscrow(s3).paymentAmount, 34, "final lot takes the rounding remainder");
+
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.FULLY_ACCEPTED));
+ assertEq(
+ dm.getSecondaryEscrow(s1).paymentAmount + dm.getSecondaryEscrow(s2).paymentAmount
+ + dm.getSecondaryEscrow(s3).paymentAmount,
+ 100,
+ "settlements sum to the full offer consideration"
+ );
+ }
+
+ function test_RevertIf_AcceptOffer_Sell_UnitsExceedOffer() public {
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS + 1,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.UnitsExceedOffer.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_Sell_OverfillAfterPartialFill() public {
+ bytes32 offerId = _postSellOffer();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS / 2,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ dm.acceptOffer(p);
+
+ p.units = UNITS / 2 + 1; // one more than remaining (reverts before signature check)
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.UnitsExceedOffer.selector);
+ dm.acceptOffer(p);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // acceptOffer — buy offer
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_RevertIf_AcceptOffer_Buy_CannotAcceptTwice() public {
+ bytes32 offerId = _postBid();
+
+ // Full fill — succeeds
+ _acceptBid(offerId);
+
+ // Second attempt reverts because offer is now FULLY_ACCEPTED
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: sellerTokenId,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: OPEN_ENDORSEMENT_SIG
+ });
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.OfferNotAvailable.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_RevertIf_AcceptOffer_Buy_UnitsExceedOffer() public {
+ bytes32 offerId = _postBid();
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS + 1,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: sellerTokenId,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: OPEN_ENDORSEMENT_SIG
+ });
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.UnitsExceedOffer.selector);
+ dm.acceptOffer(p);
+ }
+
+ function test_AcceptOffer_Buy_SingleFullyFills() public {
+ // LIVE → FULLY_ACCEPTED directly in a single fill, with no PARTIALLY_ACCEPTED step.
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+
+ // Bid is pre-funded at postOffer; acceptance must migrate, not pull additional funds.
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "bid pre-funded at postOffer");
+
+ bytes32 settlementId = _acceptBid(offerId);
+
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 1);
+
+ // ∅ → ACCEPTED: the escrow opens already-funded, migrated from the holding escrow (acceptor = seller).
+ _assertAcceptedEscrow(settlementId, offerId, seller, UNITS, CONSIDERATION);
+
+ // True state behind the enum: funds stay in custody (no second transfer) and the seller's
+ // units are reserved at acceptance (bids reserve on accept, not at postOffer).
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "funds remain in DealManager");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "seller units reserved at bid acceptance");
+
+ // Bid: the acceptor (seller) signs the open endorsement at acceptance; the signature is captured on the
+ // escrow (asserted by _assertAcceptedEscrow above), not written to the token until finalize.
+ }
+
+ function test_AcceptOffer_Buy_MultipleFills() public {
+ // Walks the full LIVE → PARTIALLY_ACCEPTED → FULLY_ACCEPTED lifecycle in two fills.
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+ uint256 firstUnits = UNITS / 2;
+ uint256 secondUnits = UNITS - firstUnits;
+ uint256 expectedFirst = CONSIDERATION * firstUnits / UNITS;
+ uint256 expectedSecond = CONSIDERATION * secondUnits / UNITS;
+
+ bytes32 settlementId1 = _acceptBidPartial(offerId, firstUnits);
+
+ // LIVE → PARTIALLY_ACCEPTED on the first fill
+ _assertOfferState(offerId, baseline, OfferStatus.PARTIALLY_ACCEPTED, firstUnits, expectedFirst, 0, 1);
+ _assertAcceptedEscrow(settlementId1, offerId, seller, firstUnits, expectedFirst);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), firstUnits, "first lot reserves its own units at acceptance");
+
+ bytes32 settlementId2 = _acceptBidPartial(offerId, secondUnits);
+
+ // PARTIALLY_ACCEPTED → FULLY_ACCEPTED: each fill is its own pro-rata settlement
+ assertTrue(settlementId1 != settlementId2, "each fill gets its own settlement escrow");
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 2);
+ // Each lot's escrow is independent and immutable: the second fill leaves the first untouched.
+ _assertAcceptedEscrow(settlementId1, offerId, seller, firstUnits, expectedFirst);
+ _assertAcceptedEscrow(settlementId2, offerId, seller, secondUnits, expectedSecond);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "both lots now reserved");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "pre-funded consideration stays in custody");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // finalizeSecondaryTradeAgreement — secondary settlements
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_FinalizeSecondaryTrade_Sell() public {
+ // The offer stays FULLY_ACCEPTED until every lot settles; it only reaches FINALIZED once the
+ // last outstanding lot is finalized.
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementIdA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptSellOfferPartial(offerId, unitsB);
+
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 companyBefore = paymentToken.balanceOf(company);
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "both lots funded in custody");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "all units reserved at postOffer");
+ assertEq(certPrinter.balanceOf(buyer), 0, "no buyer cert minted before finalize");
+
+ // Finalize lot A: only this lot settles. The offer stays FULLY_ACCEPTED (unitsFinalized lags
+ // unitsAccepted), lot A's units are consumed while lot B stays reserved, seller paid pro-rata.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ // lot A settles: unitsFinalized advances, offer stays FULLY_ACCEPTED until every lot settles
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, unitsA, 2);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot A escrow FINALIZED"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "lot B still ACCEPTED"
+ );
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller paid lot A");
+ assertEq(_consumed(sellerTokenId), unitsA, "lot A units consumed at finalize");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "lot B units stay reserved while in-flight");
+ assertEq(certPrinter.balanceOf(buyer), 1, "buyer cert minted: secondaryTransfer fired on finalize");
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "only lot B's payment remains in custody");
+ // secondaryTransfer materializes the seller's endorsement on the Ledger Entry Token at finalize
+ // (spec §7.4A): the signature signed in blank now carries the now-known buyer as endorsee, bound to the
+ // settlement agreement. Index 1 — index 0 is the issuer endorsement written at mint.
+ // (Concrete CyberCertPrinter cast: the ICyberCertPrinter interface's getEndorsementHistory return is stale.)
+ Endorsement memory sellerEndorsement =
+ CyberCertPrinter(address(certPrinter)).getEndorsementHistory(sellerTokenId, 1);
+ assertEq(sellerEndorsement.endorser, seller, "endorser is the seller");
+ assertEq(sellerEndorsement.endorsee, buyer, "endorsee is the now-known buyer");
+ assertEq(sellerEndorsement.agreementId, settlementIdA, "endorsement bound to the settlement agreement");
+ assertEq(sellerEndorsement.endorseeName, SELL_ACCEPT_BUYER_NAME, "buyer name materialized on the endorsement");
+ assertEq(sellerEndorsement.signatureHash, OPEN_ENDORSEMENT_SIG, "seller's open-endorsement signature");
+
+ // Finalize lot B (the last lot): offer reaches FINALIZED, reservation fully consumed, custody drained.
+ vm.expectEmit(true, false, false, true);
+ emit ISecondaryTradeStorage.SecondaryTradeAgreementFinalized(settlementIdB, seller, buyer, unitsB, lotB);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdB);
+
+ // FULLY_ACCEPTED → FINALIZED once the last lot settles
+ _assertOfferState(offerId, baseline, OfferStatus.FINALIZED, UNITS, CONSIDERATION, UNITS, 2);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot B escrow FINALIZED"
+ );
+ assertEq(
+ paymentToken.balanceOf(seller),
+ sellerBefore + CONSIDERATION,
+ "seller received full consideration across lots"
+ );
+ assertEq(_consumed(sellerTokenId), UNITS, "all units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after last lot finalized");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ assertEq(paymentToken.balanceOf(company), companyBefore, "company payable untouched on secondary path");
+ }
+
+ // Accepting a SELL offer keeps the seller's units reserved (escrowed) through settlement, so legal ownership
+ // stays frozen between acceptance and finalize: re-registering the seller's Ledger Entry Token reverts at the
+ // source.
+ function test_RevertIf_AssignReservedCert_AfterAcceptSell() public {
+ address newOwner = makeAddr("newLegalOwner");
+ bytes32 offerId = _postSellOffer();
+ _acceptSellOffer(offerId);
+
+ vm.prank(owner);
+ vm.expectRevert(CyberCertPrinter.CertificateReserved.selector);
+ im.assignCert(address(certPrinter), seller, sellerTokenId, newOwner, _sellerCertDetails(UNITS));
+
+ // Below verifies DealManager does double-check the legal ownership at every step, but we will never reach there if
+ // the cert's reserve rule works as expected
+// assertEq(certPrinter.legalOwnerOf(sellerTokenId), newOwner, "legal owner moved off the seller");
+//
+// vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeSellerOwnershipChanged.selector);
+// vm.prank(keeper);
+// dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ // BUY mirror: accepting a bid reserves the acceptor's (seller-of-record) units, so re-registering their
+ // Ledger Entry Token before finalize likewise reverts at the source.
+ function test_RevertIf_AssignReservedCert_AfterAcceptBuy() public {
+ address newOwner = makeAddr("newLegalOwner");
+ bytes32 offerId = _postBid();
+ _acceptBid(offerId);
+
+ vm.prank(owner);
+ vm.expectRevert(CyberCertPrinter.CertificateReserved.selector);
+ im.assignCert(address(certPrinter), seller, sellerTokenId, newOwner, _sellerCertDetails(UNITS));
+
+ // Below verifies DealManager does double-check the legal ownership at every step, but we will never reach there if
+ // the cert's reserve rule works as expected
+// assertEq(certPrinter.legalOwnerOf(sellerTokenId), newOwner, "legal owner moved off the acceptor");
+//
+// vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeSellerOwnershipChanged.selector);
+// vm.prank(keeper);
+// dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ function test_FinalizeSecondaryTrade_Buy() public {
+ // BUY mirror: the bid stays FULLY_ACCEPTED until every lot settles, reaching FINALIZED only
+ // once the last outstanding lot is finalized.
+ uint256 unitsA = 40;
+ uint256 unitsB = 60;
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementIdA = _acceptBidPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptBidPartial(offerId, unitsB);
+
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 companyBefore = paymentToken.balanceOf(company);
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "bid pre-funded in custody");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "both lots reserved at acceptance");
+ assertEq(certPrinter.balanceOf(buyer), 0, "no buyer cert minted before finalize");
+
+ // Finalize lot A: only this lot settles. The bid stays FULLY_ACCEPTED, lot A's units are
+ // consumed while lot B stays reserved, the acceptor (seller) is paid pro-rata.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ // lot A settles: unitsFinalized advances, bid stays FULLY_ACCEPTED until every lot settles
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, unitsA, 2);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot A escrow FINALIZED"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "lot B still ACCEPTED"
+ );
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "acceptor (seller) paid lot A");
+ assertEq(_consumed(sellerTokenId), unitsA, "lot A units consumed at finalize");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "lot B units stay reserved while in-flight");
+ assertEq(certPrinter.balanceOf(buyer), 1, "buyer cert minted: secondaryTransfer fired on finalize");
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "only lot B's payment remains in custody");
+ // secondaryTransfer materializes the seller's endorsement on the Ledger Entry Token at finalize
+ // (spec §7.4A): the acceptor (seller) is the endorser; the bidder/offeror is the now-known endorsee.
+ // Index 1 — index 0 is the issuer endorsement written at mint. The bid carries the buyer name; the
+ // acceptance carries the signature. (Concrete CyberCertPrinter cast: the interface return is stale.)
+ Endorsement memory sellerEndorsement =
+ CyberCertPrinter(address(certPrinter)).getEndorsementHistory(sellerTokenId, 1);
+ assertEq(sellerEndorsement.endorser, seller, "endorser is the acceptor (seller)");
+ assertEq(sellerEndorsement.endorsee, buyer, "endorsee is the bidder (buyer)");
+ assertEq(sellerEndorsement.agreementId, settlementIdA, "endorsement bound to the settlement agreement");
+ assertEq(sellerEndorsement.endorseeName, dm.getOffer(offerId).buyerName, "buyer name from the bid offer");
+ assertEq(sellerEndorsement.signatureHash, OPEN_ENDORSEMENT_SIG, "signature from the bid acceptance");
+
+ // Finalize lot B (the last lot): bid reaches FINALIZED, reservation fully consumed, custody drained.
+ vm.expectEmit(true, false, false, true);
+ emit ISecondaryTradeStorage.SecondaryTradeAgreementFinalized(settlementIdB, seller, buyer, unitsB, lotB);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdB);
+
+ // FULLY_ACCEPTED → FINALIZED once the last lot settles
+ _assertOfferState(offerId, baseline, OfferStatus.FINALIZED, UNITS, CONSIDERATION, UNITS, 2);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot B escrow FINALIZED"
+ );
+ assertEq(
+ paymentToken.balanceOf(seller),
+ sellerBefore + CONSIDERATION,
+ "acceptor received full consideration across lots"
+ );
+ assertEq(_consumed(sellerTokenId), UNITS, "all units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after last lot finalized");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ assertEq(paymentToken.balanceOf(company), companyBefore, "company payable untouched on secondary path");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // finalize after offer cancellation — accepted lots remain finalizable
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_FinalizeSecondaryTrade_Sell_AfterOfferCancellation() public {
+ // Cancelling a SELL offer does not disturb its accepted lots: an already-finalized lot is left
+ // untouched, a still-ACCEPTED lot stays reserved and remains finalizable after the cancel, and
+ // cancel releases only the free (uncommitted) units. Folds the SELL CancelKeepingLots /
+ // KeepsActiveLots cases.
+ uint256 unitsA = 40; // finalized before cancel
+ uint256 unitsB = 30; // still ACCEPTED at cancel, finalized after
+ uint256 freeUnits = UNITS - unitsA - unitsB; // 30, released at cancel
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementIdA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptSellOfferPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+
+ // Finalize lot A before the cancel so we can prove the cancel leaves it untouched.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // Cancel: offer CANCELLED, finalized lot untouched, active lot survives, only free units released.
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "finalized lot untouched by cancel"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "active lot stays ACCEPTED after cancel"
+ );
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller keeps the finalized payout");
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "active lot's payment stays in custody");
+ assertEq(_consumed(sellerTokenId), unitsA, "finalized lot's units consumed exactly once");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "active lot stays reserved");
+ assertEq(_released(sellerTokenId), freeUnits, "only the free units released at cancel");
+
+ // The active lot is still finalizable after the offer was cancelled.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdB);
+
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "CANCELLED stays sticky");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "active lot finalized after cancel"
+ );
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA + lotB, "active lot settles after cancel");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ assertEq(_consumed(sellerTokenId), unitsA + unitsB, "both finalized lots' units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after last lot finalized");
+ }
+
+ function test_FinalizeSecondaryTrade_Buy_AfterOfferCancellation() public {
+ // BUY mirror: cancelling a bid refunds only the free (uncommitted) consideration to the buyer;
+ // an already-finalized lot is untouched and a still-ACCEPTED lot remains finalizable after the
+ // cancel, paying the acceptor (seller). Folds the BUY CancelKeepingLots / KeepsActiveLots cases.
+ uint256 unitsA = 40; // finalized before cancel
+ uint256 unitsB = 30; // still ACCEPTED at cancel, finalized after
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+ uint256 freePool = CONSIDERATION - lotA - lotB; // refunded to buyer at cancel
+
+ bytes32 offerId = _postBid();
+ bytes32 settlementIdA = _acceptBidPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptBidPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+
+ // Finalize lot A before the cancel so we can prove the cancel leaves it untouched.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // Cancel: offer CANCELLED, finalized lot untouched, active lot survives, only the free pool refunded.
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "finalized lot untouched by cancel"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "active lot stays ACCEPTED after cancel"
+ );
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "acceptor keeps the finalized payout");
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBefore + freePool,
+ "cancel refunds only the free pool, excludes finalized and active lots"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "active lot's funds stay in custody");
+ assertEq(_consumed(sellerTokenId), unitsA, "finalized lot's units consumed exactly once");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "active lot stays reserved");
+
+ // The active lot is still finalizable after the offer was cancelled.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdB);
+
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "CANCELLED stays sticky");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "active lot finalized after cancel"
+ );
+ assertEq(
+ paymentToken.balanceOf(seller),
+ sellerBefore + lotA + lotB,
+ "active lot settles after cancel, paid to acceptor"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ assertEq(_consumed(sellerTokenId), unitsA + unitsB, "both finalized lots' units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after last lot finalized");
+ }
+
+ function test_RevertIf_CancelOffer_AlreadyFinalized() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.OfferNotAvailable.selector);
+ dm.cancelOffer(offerId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // voidExpiredSecondaryTradeAgreement — secondary settlements
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_VoidExpiredSecondaryTradeAgreement() public {
+ // Representative for the expiry path: voidExpired converges on the same _voidSecondaryTradeAgreement as the
+ // void/sync paths, so one peer-level case suffices (the before/after-cancel branches are covered
+ // through the void path). On a non-cancelled offer the expired settlement voids → offer reverts
+ // to LIVE, the acceptor (buyer) is refunded, custody drains, and the SELL reservation is HELD
+ // (returned to the free pool, not released) so future acceptors stay protected — the seller must
+ // cancelOffer() to release it.
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+
+ vm.warp(dm.getSecondaryEscrow(settlementId).expiry + 1);
+ vm.expectEmit(true, false, false, false);
+ emit ISecondaryTradeStorage.SecondaryTradeAgreementVoided(settlementId);
+ vm.prank(keeper);
+ dm.voidExpiredSecondaryTradeAgreement(settlementId, buyer, "");
+
+ // settlement VOIDED, offer reverts to LIVE
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.VOIDED),
+ "expired settlement VOIDED"
+ );
+ _assertOfferState(offerId, baseline, OfferStatus.LIVE, 0, 0, 0, 1);
+ // units: reservation held, nothing released or consumed
+ assertEq(
+ certPrinter.unitsReserved(sellerTokenId), UNITS, "reservation held: offer LIVE, future acceptors protected"
+ );
+ assertEq(_released(sellerTokenId), 0, "reservation not released while the offer stays open");
+ assertEq(_consumed(sellerTokenId), 0, "voided lot never consumed");
+ // money: acceptor refunded, custody drained
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + CONSIDERATION, "acceptor refunded on expiry void");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ }
+
+ function test_RevertIf_VoidExpiredSecondaryTradeAgreement_AlreadyVoided() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.warp(dm.getSecondaryEscrow(settlementId).expiry + 1);
+ vm.prank(keeper);
+ dm.voidExpiredSecondaryTradeAgreement(settlementId, buyer, "");
+
+ uint256 buyerAfterVoid = paymentToken.balanceOf(buyer);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided.selector);
+ vm.prank(keeper);
+ dm.voidExpiredSecondaryTradeAgreement(settlementId, buyer, "");
+
+ assertEq(paymentToken.balanceOf(buyer), buyerAfterVoid, "buyer must not be refunded twice");
+ }
+
+ function test_RevertIf_VoidExpiredSecondaryTradeAgreement_AlreadyFinalized() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.warp(dm.getSecondaryEscrow(settlementId).expiry + 1);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyFinalized.selector);
+ vm.prank(keeper);
+ dm.voidExpiredSecondaryTradeAgreement(settlementId, buyer, "");
+ }
+
+ function test_RevertIf_VoidExpiredSecondaryTradeAgreement_NotExpired() public {
+ // voidExpired only applies past the settlement's expiry — the distinct guard of the expiry path.
+ // Before expiry it must refuse and leave the settlement intact and finalizable.
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ // Not warped past expiry.
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementNotExpired.selector);
+ vm.prank(keeper);
+ dm.voidExpiredSecondaryTradeAgreement(settlementId, buyer, "");
+
+ // Untouched and still finalizable.
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "escrow stays ACCEPTED"
+ );
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "units stay reserved");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "consideration stays in custody");
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "still finalizable before expiry"
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // hasSecondaryEscrow discriminator
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_HasSecondaryEscrow_TrueAfterAccept() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ SecondaryEscrow memory se = dm.getSecondaryEscrow(settlementId);
+ assertTrue(se.counterparty != address(0), "SecondaryEscrow should exist after accept");
+ }
+
+ // A secondary settlement function rejects a primary deal id (no SecondaryEscrow exists for it).
+ // The reverse direction — primary functions rejecting a secondary/unknown id — is a primary-function
+ // concern and lives in DealManagerTest.t.sol.
+ function test_RevertIf_FinalizeSecondaryTradeAgreement_OnPrimaryDeal() public {
+ bytes32 agreementId = _proposePrimaryDeal(995);
+
+ vm.prank(keeper);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryEscrowNotFound.selector);
+ dm.finalizeSecondaryTradeAgreement(agreementId);
+ }
+
+ function _acceptSellOfferPartial(bytes32 offerAgreementId, uint256 units)
+ internal
+ returns (bytes32 settlementAgreementId)
+ {
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerAgreementId,
+ units: units,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerAgreementId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ settlementAgreementId = dm.acceptOffer(p);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // voidSecondaryTradeAgreement — reusability + void around offer cancellation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_VoidSecondaryTradeAgreement_Sell_FullyAcceptedToLive() public {
+ // Walks FULLY_ACCEPTED → PARTIALLY_ACCEPTED → LIVE by voiding the accepted lots one at a
+ // time, then proves the offer is genuinely reusable by running a fresh acceptance lifecycle.
+ // Each void refunds its buyer immediately and returns units to the free pool, but the SELL
+ // reservation stays held throughout — the offer is never cancelled.
+ bytes32 offerId = _postSellOffer();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 lotA = _acceptSellOfferPartial(offerId, 40);
+ bytes32 lotB = _acceptSellOfferPartial(offerId, 60);
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 2);
+
+ // FULLY_ACCEPTED → PARTIALLY_ACCEPTED: void one of the two lots, the other survives.
+ uint256 buyerBeforeB = paymentToken.balanceOf(buyer);
+ _voidSettlementBothParties(lotB);
+
+ _assertOfferState(offerId, baseline, OfferStatus.PARTIALLY_ACCEPTED, 40, CONSIDERATION * 40 / UNITS, 0, 2);
+ assertEq(
+ certPrinter.unitsReserved(sellerTokenId), UNITS, "voided lot returns to free pool, reservation stays held"
+ );
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBeforeB + CONSIDERATION * 60 / UNITS,
+ "voided lot's buyer is refunded immediately"
+ );
+
+ // PARTIALLY_ACCEPTED → LIVE: void the last surviving lot, unitsAccepted → 0.
+ uint256 buyerBeforeA = paymentToken.balanceOf(buyer);
+ _voidSettlementBothParties(lotA);
+
+ _assertOfferState(offerId, baseline, OfferStatus.LIVE, 0, 0, 0, 2);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "reservation held: offer is LIVE, not cancelled");
+ assertEq(_released(sellerTokenId), 0);
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBeforeA + CONSIDERATION * 40 / UNITS,
+ "last lot's buyer refunded the pro-rata payment"
+ );
+
+ // Ready for another lifecycle: the reverted-to-LIVE offer accepts a fresh full fill.
+ bytes32 reaccept = _acceptSellOffer(offerId);
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 3);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "held reservation backs the fresh fill");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(reaccept).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "fresh settlement opens ACCEPTED"
+ );
+ assertTrue(reaccept != lotA && reaccept != lotB, "fresh settlement is distinct from the voided lots");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Buy_FullyAcceptedToLive() public {
+ // BUY mirror: a void releases the acceptor's (seller's) per-lot reservation immediately, and
+ // the consideration returns to the offer's free pool and stays in custody (no buyer refund
+ // while the offer is open). The bid walks FULLY_ACCEPTED → PARTIALLY_ACCEPTED → LIVE and
+ // then accepts a fresh lifecycle.
+ bytes32 offerId = _postBid();
+ Offer memory baseline = dm.getOffer(offerId);
+ bytes32 lotA = _acceptBidPartial(offerId, 40);
+ bytes32 lotB = _acceptBidPartial(offerId, 60);
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 2);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "both lots reserve the seller's units");
+
+ uint256 buyerBalance = paymentToken.balanceOf(buyer);
+
+ // FULLY_ACCEPTED → PARTIALLY_ACCEPTED: void one lot; the seller's lot reservation is released.
+ _voidSettlementBothParties(lotB);
+
+ _assertOfferState(offerId, baseline, OfferStatus.PARTIALLY_ACCEPTED, 40, CONSIDERATION * 40 / UNITS, 0, 2);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 40, "voided lot's seller reservation released");
+ assertEq(paymentToken.balanceOf(buyer), buyerBalance, "no buyer refund while offer is open");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "consideration stays whole in custody");
+
+ // PARTIALLY_ACCEPTED → LIVE: void the last lot, unitsAccepted → 0.
+ _voidSettlementBothParties(lotA);
+
+ _assertOfferState(offerId, baseline, OfferStatus.LIVE, 0, 0, 0, 2);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "all seller reservations released");
+ assertEq(paymentToken.balanceOf(buyer), buyerBalance, "still no buyer refund: offer open, not cancelled");
+ assertEq(
+ paymentToken.balanceOf(address(dm)),
+ CONSIDERATION,
+ "full consideration in custody, ready to back a fresh fill"
+ );
+
+ // Ready for another lifecycle: the reverted-to-LIVE bid accepts a fresh full fill.
+ bytes32 reaccept = _acceptBid(offerId);
+ _assertOfferState(offerId, baseline, OfferStatus.FULLY_ACCEPTED, UNITS, CONSIDERATION, 0, 3);
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "fresh fill re-reserves the seller's units");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(reaccept).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "fresh settlement opens ACCEPTED"
+ );
+ assertTrue(reaccept != lotA && reaccept != lotB, "fresh settlement is distinct from the voided lots");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Sell_AfterOfferCancellation() public {
+ // Mixed-state exactly-once accounting for a cancelled SELL offer that holds one FINALIZED lot,
+ // one ACCEPTED lot, and a free pool. The point is not that voiding lot B leaves the already-
+ // terminal lot A alone (it trivially does — void only touches B's escrow); it is that across
+ // finalize → cancel → void, every unit and every token nets out exactly once: cancel releases
+ // only the free units, the void releases lot B's reservation and refunds its consideration to
+ // the acceptor (buyer), and lot A's consumed units / disbursed payout are double-counted by
+ // neither. Folds the SELL AfterCancel case.
+ uint256 unitsA = 40; // finalized before cancel
+ uint256 unitsB = 30; // ACCEPTED at cancel, voided after
+ uint256 freeUnits = UNITS - unitsA - unitsB; // 30, released at cancel
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementIdA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptSellOfferPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer); // buyer is the acceptor on a sell offer
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // After cancel: offer CANCELLED, lots intact; only the free units are released.
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot A FINALIZED"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "lot B still ACCEPTED"
+ );
+ // units
+ assertEq(_consumed(sellerTokenId), unitsA, "only the finalized lot's units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "active lot stays reserved");
+ assertEq(_released(sellerTokenId), freeUnits, "cancel releases only the free units");
+ // money
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller holds the finalized payout");
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore, "acceptor not refunded at cancel (active lot still held)");
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "active lot's consideration stays in custody");
+
+ // Void the remaining ACCEPTED lot after cancellation.
+ _voidSettlementBothParties(settlementIdB);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status), uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED"
+ );
+ // units: voided lot's reservation released; finalized lot's consumed units counted exactly once
+ assertEq(_consumed(sellerTokenId), unitsA, "finalized units counted once; voided lot never consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "voided lot's reservation released");
+ assertEq(
+ _released(sellerTokenId), freeUnits + unitsB, "released == free units + voided lot, finalized excluded"
+ );
+ // money: voided lot refunded to acceptor; finalized payout untouched; custody fully drained
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller still holds only the finalized payout");
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + lotB, "acceptor refunded the voided lot");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained: every token left exactly once");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Sell_BeforeOfferCancellation() public {
+ // Pairs with VoidSecondaryTradeAgreement_Sell_AfterOfferCancellation: same finalized + voided +
+ // free mixed state, but here the void happens BEFORE the cancel. The order flips the offeror-
+ // asset branch (SecondaryTradeStorage `_voidSecondaryTradeAgreement`). A SELL offer's units are reserved at
+ // postOffer, so while the offer is still open the voided lot's units stay reserved (return to the
+ // offer's free pool, NOT released); the later cancel releases them. The acceptor's asset is
+ // returned immediately either way — here the buyer's consideration is refunded at the void. This
+ // is the SELL mirror of Buy_BeforeOfferCancellation, with the units/payment roles swapped: for
+ // SELL the money settles at the void and the units settle at the cancel.
+ uint256 unitsA = 40; // finalized
+ uint256 unitsB = 30; // voided while offer still open
+ uint256 freeUnits = UNITS - unitsA - unitsB; // 30, never accepted
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementIdA = _acceptSellOfferPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptSellOfferPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer); // buyer is the acceptor on a sell offer
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ // Void the active lot while the offer is still open: offeror units stay reserved (false arm),
+ // acceptor's consideration refunded immediately.
+ _voidSettlementBothParties(settlementIdB);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status), uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED"
+ );
+ // units: voided lot's units return to the free pool but stay reserved while the offer is open
+ assertEq(_consumed(sellerTokenId), unitsA, "only the finalized lot's units consumed");
+ assertEq(
+ certPrinter.unitsReserved(sellerTokenId),
+ UNITS - unitsA,
+ "voided lot's units stay reserved while offer open"
+ );
+ assertEq(_released(sellerTokenId), 0, "void while open releases no units (offer can still re-accept)");
+ // money: acceptor (buyer) refunded the voided lot immediately; finalized payout untouched; custody drained
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller keeps only the finalized payout");
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + lotB, "acceptor refunded the voided lot at void");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "voided lot refunded; only the finalized lot had left custody");
+
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+
+ // Cancel now releases the whole free pool (original remainder + the voided lot's returned units);
+ // the money is already settled, so it stays put.
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(_consumed(sellerTokenId), unitsA, "finalized units counted exactly once");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "all free units released at cancel");
+ assertEq(_released(sellerTokenId), freeUnits + unitsB, "released == free units + voided lot's returned units");
+ assertEq(
+ paymentToken.balanceOf(seller),
+ sellerBefore + lotA,
+ "money already settled at void: seller unchanged by cancel"
+ );
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBefore + lotB,
+ "money already settled at void: buyer unchanged by cancel"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody stays empty: every token left exactly once");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Buy_AfterOfferCancellation() public {
+ // BUY mirror, structurally identical to the SELL case above: mixed-state exactly-once
+ // accounting for a cancelled bid that holds one FINALIZED lot, one ACCEPTED lot, and a free
+ // pool. Across finalize → cancel → void every unit and every token nets out once. The
+ // side-specific differences are commented inline: a bid's offeror asset is money, so cancel
+ // refunds the free *consideration* (rather than releasing free units), and the acceptor here is
+ // the seller. Folds the BUY AfterCancel case.
+ uint256 unitsA = 40; // finalized before cancel
+ uint256 unitsB = 30; // ACCEPTED at cancel, voided after
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+ uint256 freePool = CONSIDERATION - lotA - lotB; // free consideration, refunded at cancel
+
+ bytes32 offerId = _postBid();
+ bytes32 settlementIdA = _acceptBidPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptBidPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller); // seller is the acceptor on a bid
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // After cancel: offer CANCELLED, lots intact; only the free consideration is refunded.
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED), "offer CANCELLED");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdA).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "lot A FINALIZED"
+ );
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "lot B still ACCEPTED"
+ );
+ // units
+ assertEq(_consumed(sellerTokenId), unitsA, "only the finalized lot's units consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), unitsB, "active lot stays reserved");
+ // (bid reserves per-lot, so "no units released on cancel" is captured by the active lot staying reserved above)
+ // money
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "acceptor holds the finalized payout");
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + freePool, "buyer refunded only the free consideration");
+ assertEq(paymentToken.balanceOf(address(dm)), lotB, "active lot's consideration stays in custody");
+
+ // Void the remaining ACCEPTED lot after cancellation.
+ _voidSettlementBothParties(settlementIdB);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status), uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED"
+ );
+ // units: voided lot's reservation released; finalized lot's consumed units counted exactly once
+ assertEq(_consumed(sellerTokenId), unitsA, "finalized units counted once; voided lot never consumed");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "voided lot's reservation released");
+ // (bid: only the voided lot's reservation is freed — captured by reserved dropping to 0 with consumed==unitsA)
+ // money: voided lot refunded to buyer; finalized payout untouched; custody fully drained
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "acceptor still holds only the finalized payout");
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + freePool + lotB, "buyer refunded free pool + voided lot");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained: every token left exactly once");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Buy_BeforeOfferCancellation() public {
+ // Pairs with VoidSecondaryTradeAgreement_Buy_AfterOfferCancellation: same finalized + voided +
+ // free mixed state, but here the void happens BEFORE the cancel. The order flips the void refund
+ // branch (SecondaryTradeStorage `_voidSecondaryTradeAgreement`): while the offer is still open the voided
+ // lot's consideration returns to the free pool and stays in custody (no direct refund); the
+ // later cancel then sweeps it out. Either way every unit of consideration leaves custody exactly
+ // once — finalized lot via payout, voided lot via the free pool then the cancel refund.
+ uint256 unitsA = 40; // finalized
+ uint256 unitsB = 30; // voided while offer still open
+ uint256 lotA = CONSIDERATION * unitsA / UNITS;
+ uint256 lotB = CONSIDERATION * unitsB / UNITS;
+ uint256 freePool = CONSIDERATION - lotA - lotB; // never-accepted remainder
+
+ bytes32 offerId = _postBid();
+ bytes32 settlementIdA = _acceptBidPartial(offerId, unitsA);
+ bytes32 settlementIdB = _acceptBidPartial(offerId, unitsB);
+ uint256 sellerBefore = paymentToken.balanceOf(seller); // seller is the acceptor on a bid
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementIdA);
+
+ // Void the active lot while the offer is still open: the false arm of the refund branch.
+ _voidSettlementBothParties(settlementIdB);
+
+ // No direct refund yet — the voided lot's consideration joins the free pool and stays in custody.
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementIdB).status), uint8(SecondaryEscrowStatus.VOIDED), "lot B VOIDED"
+ );
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBefore,
+ "void while open does not refund; consideration returns to free pool"
+ );
+ assertEq(
+ paymentToken.balanceOf(address(dm)), lotB + freePool, "voided lot + free pool held in custody until cancel"
+ );
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ // Cancel sweeps the whole free pool (original remainder + the voided lot) out to the buyer,
+ // excluding only the finalized lot's disbursed payout.
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + lotA, "seller keeps only the finalized payout");
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBefore + freePool + lotB,
+ "refund covers free pool plus voided lot, excludes finalized lot"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained: every token left exactly once");
+ }
+
+ function test_CancelOffer_Bid_AfterFinalizedFill_RefundsOnlyFreePool() public {
+ // A finalized lot's payment is disbursed to the seller and must never return to the
+ // offer's free pool: paymentAccepted keeps counting it (decrements on void only).
+ bytes32 offerId = _postBid();
+ bytes32 settlementId = _acceptBidPartial(offerId, 40);
+ uint256 lotPayment = CONSIDERATION * 40 / UNITS;
+ uint256 buyerBalanceAfterAccept = paymentToken.balanceOf(buyer);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(dm.getOffer(offerId).paymentAccepted, lotPayment, "finalized lot stays counted as accepted");
+
+ vm.prank(buyer);
+ dm.cancelOffer(offerId);
+
+ assertEq(
+ paymentToken.balanceOf(buyer),
+ buyerBalanceAfterAccept + (CONSIDERATION - lotPayment),
+ "cancel refunds only the free pool, not the disbursed lot"
+ );
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained, nothing over-refunded");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Terminal-state guards on settled escrows
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_RevertIf_VoidSecondaryTradeAgreement_AlreadyVoided() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ _voidSettlementBothParties(settlementId);
+
+ uint256 buyerAfterVoid = paymentToken.balanceOf(buyer);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided.selector);
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+
+ assertEq(paymentToken.balanceOf(buyer), buyerAfterVoid, "buyer must not be refunded twice");
+ }
+
+ function test_RevertIf_VoidSecondaryTradeAgreement_AlreadyFinalized() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyFinalized.selector);
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+ }
+
+ function test_RevertIf_SyncVoidedSecondaryTradeAgreement_AlreadyVoided() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ _voidSettlementBothParties(settlementId);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided.selector);
+ dm.syncVoidedSecondaryTradeAgreement(settlementId);
+ }
+
+ function test_RevertIf_SyncVoidedSecondaryTradeAgreement_AlreadyFinalized() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyFinalized.selector);
+ dm.syncVoidedSecondaryTradeAgreement(settlementId);
+ }
+
+ function test_RevertIf_SyncVoidedSecondaryTradeAgreement_NotVoided() public {
+ // sync is only a mirror: with no registry-side void (or only a lone, sub-quorum request) it must
+ // refuse and leave the settlement fully intact and finalizable — the sync counterpart of the
+ // two-party quorum guard. Here one party voids at the registry, which is not enough for quorum.
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ registry.voidContractFor(settlementId, buyer, _voidSig(settlementId, buyer, buyerKey));
+ assertFalse(registry.isVoided(settlementId), "lone registry request: not voided yet");
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementNotVoided.selector);
+ dm.syncVoidedSecondaryTradeAgreement(settlementId);
+
+ // Untouched and still finalizable.
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "escrow stays ACCEPTED"
+ );
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "units stay reserved");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "consideration stays in custody");
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "still finalizable after a refused sync"
+ );
+ }
+
+ function test_RevertIf_FinalizeSecondaryTrade_AlreadyFinalized() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyFinalized.selector);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ function test_RevertIf_FinalizeSecondaryTrade_AlreadyVoided() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ _voidSettlementBothParties(settlementId);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided.selector);
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ function test_VoidSecondaryTradeAgreement_Sell_SingleRequest_StillFinalizable() public {
+ // A void needs a two-party quorum: one party's request records intent only and must leave the
+ // settlement fully intact and finalizable. Asserts the escrow/offer state and custody both
+ // before and after the lone request, then a clean finalize. SELL: the acceptor (buyer) requests.
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+
+ // One party's void request only records intent; it must not void locally or touch any assets.
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "single request must not void the escrow"
+ );
+ assertFalse(registry.isVoided(settlementId), "registry not voided on a single request");
+ assertEq(
+ uint8(dm.getOffer(offerId).status), uint8(OfferStatus.FULLY_ACCEPTED), "offer unchanged by the lone request"
+ );
+ assertEq(dm.getOffer(offerId).paymentAccepted, CONSIDERATION, "committed consideration unchanged");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "units stay reserved after a lone request");
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION, "consideration stays in custody, nothing refunded");
+
+ // The counterparty can still finalize, and it settles exactly like an untouched settlement.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "still finalizable after a lone void request"
+ );
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.FINALIZED), "offer reaches FINALIZED");
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + CONSIDERATION, "seller paid in full at finalize");
+ assertEq(_consumed(sellerTokenId), UNITS, "all units consumed at finalize");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after finalize");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ }
+
+ function test_VoidSecondaryTradeAgreement_Buy_SingleRequest_StillFinalizable() public {
+ // BUY mirror: same two-party quorum rule. A bid pre-funds consideration at postOffer and the
+ // acceptor (seller) is reserved at accept; a lone void request must leave all of that intact and
+ // finalizable. BUY: the acceptor (seller) requests.
+ bytes32 offerId = _postBid();
+ bytes32 settlementId = _acceptBid(offerId);
+ uint256 sellerBefore = paymentToken.balanceOf(seller); // seller is the acceptor on a bid
+
+ // One party's void request only records intent; it must not void locally or touch any assets.
+ vm.prank(seller);
+ dm.voidSecondaryTradeAgreement(settlementId, seller, "");
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "single request must not void the escrow"
+ );
+ assertFalse(registry.isVoided(settlementId), "registry not voided on a single request");
+ assertEq(
+ uint8(dm.getOffer(offerId).status), uint8(OfferStatus.FULLY_ACCEPTED), "offer unchanged by the lone request"
+ );
+ assertEq(dm.getOffer(offerId).paymentAccepted, CONSIDERATION, "committed consideration unchanged");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "units stay reserved after a lone request");
+ assertEq(
+ paymentToken.balanceOf(address(dm)),
+ CONSIDERATION,
+ "pre-funded consideration stays in custody, nothing refunded"
+ );
+
+ // The counterparty can still finalize, and it settles exactly like an untouched settlement.
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "still finalizable after a lone void request"
+ );
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.FINALIZED), "offer reaches FINALIZED");
+ assertEq(
+ paymentToken.balanceOf(seller), sellerBefore + CONSIDERATION, "acceptor (seller) paid in full at finalize"
+ );
+ assertEq(_consumed(sellerTokenId), UNITS, "all units consumed at finalize");
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0, "no units reserved after finalize");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ }
+
+ function test_SyncVoidedSecondaryTradeAgreement() public {
+ // The sync path's one job: detect a void done directly at the registry (bypassing DealManager)
+ // and mirror it into the local escrow via the shared _voidSecondaryTradeAgreement. Asserted to peer level
+ // so we're confident the synced settlement lands in the same state as a DealManager-driven void:
+ // escrow VOIDED, acceptor (buyer) refunded, SELL reservation returned to the free pool but kept
+ // (offer still open, not cancelled), custody drained. The before/after-cancel branches of
+ // _voidSecondaryTradeAgreement are exhaustively covered through the void path.
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer); // buyer is the acceptor on a sell offer
+
+ // Both parties void directly at the registry (bypassing DealManager). Calls don't go through
+ // the finalizer, so each needs a real EIP-712 void signature.
+ registry.voidContractFor(settlementId, buyer, _voidSig(settlementId, buyer, buyerKey));
+ registry.voidContractFor(settlementId, seller, _voidSig(settlementId, seller, sellerKey));
+ assertTrue(registry.isVoided(settlementId), "registry voided by both parties directly");
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.ACCEPTED),
+ "DealManager escrow not yet synced"
+ );
+
+ dm.syncVoidedSecondaryTradeAgreement(settlementId);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.VOIDED),
+ "escrow synced to VOIDED"
+ );
+ assertEq(
+ uint8(dm.getOffer(offerId).status),
+ uint8(OfferStatus.LIVE),
+ "offer reverts to LIVE on void (open, not cancelled)"
+ );
+ // money: acceptor refunded in full; offeror (seller) untouched; custody drained
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore + CONSIDERATION, "buyer refunded on sync");
+ assertEq(paymentToken.balanceOf(seller), sellerBefore, "seller (offeror) untouched");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ // units: voided while offer open → reservation returns to the free pool but stays held, not released
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS, "reservation held: offer open, not cancelled");
+ assertEq(_released(sellerTokenId), 0, "no units released while the offer stays open");
+ assertEq(_consumed(sellerTokenId), 0, "voided lot never consumed");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // hasSecondaryEscrow discriminator
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_HasSecondaryEscrow_FalseForPrimaryDeal() public {
+ // A primary deal should have no SecondaryEscrow entry
+ CertificateDetails[] memory certDetails = new CertificateDetails[](1);
+ address[] memory parties = new address[](2);
+ parties[0] = owner;
+ parties[1] = buyer;
+ string[][] memory partyValues = new string[][](2);
+ partyValues[0] = new string[](0);
+ partyValues[1] = new string[](0);
+ address[] memory printers = new address[](1);
+ printers[0] = address(certPrinter);
+
+ vm.prank(owner);
+ (bytes32 agreementId,) = dm.proposeDeal(
+ printers,
+ address(paymentToken),
+ CONSIDERATION,
+ bytes32(0),
+ 997,
+ new string[](0),
+ parties,
+ certDetails,
+ partyValues,
+ new address[](0),
+ bytes32(0),
+ block.timestamp + 1 days
+ );
+
+ SecondaryEscrow memory se = dm.getSecondaryEscrow(agreementId);
+ assertEq(se.counterparty, address(0), "primary deal should have no SecondaryEscrow");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Coverage gaps (see specs/analysis/dealManager secondary trades — test coverage map.md)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Offer-level expiry: acceptOffer past offer.validUntil reverts with OfferExpired. The check runs
+ // before any signature/condition work, so an empty acceptor sig still reverts here first.
+ function test_RevertIf_AcceptOffer_OfferExpired() public {
+ bytes32 offerId = _postSellOffer(); // validUntil = block.timestamp + 1 days
+
+ vm.warp(block.timestamp + 2 days);
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: "",
+ openEndorsementSig: ""
+ });
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.OfferExpired.selector);
+ dm.acceptOffer(p);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Settlement window — settlement expiry runs from acceptance, not offer expiry
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_GetSettlementWindow_DefaultsWhenUnset() public view {
+ assertEq(dm.getSettlementWindow(), 7 days, "unset window falls back to the 7-day default");
+ }
+
+ function test_SetSettlementWindow_UpdatesEffectiveWindow() public {
+ vm.expectEmit(false, false, false, true, address(dm));
+ emit DealManager.SettlementWindowSet(45 days, owner);
+ vm.prank(owner);
+ dm.setSettlementWindow(45 days);
+ assertEq(dm.getSettlementWindow(), 45 days, "configured window overrides the default");
+ }
+
+ // Core regression for the truncated-settlement-window finding: a lot accepted moments before the offer
+ // expires still gets the full settlement window measured from acceptance, not the offer's imminent validUntil.
+ function test_AcceptOffer_SettlementExpiry_RunsFromAcceptance_NotOfferExpiry() public {
+ bytes32 offerId = _postSellOffer(); // validUntil = block.timestamp + 1 days
+ uint256 offerValidUntil = dm.getOffer(offerId).validUntil;
+
+ // Accept one minute before the offer expires.
+ vm.warp(offerValidUntil - 1 minutes);
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ assertEq(
+ dm.getSecondaryEscrow(settlementId).expiry,
+ block.timestamp + 7 days,
+ "settlement expiry is acceptance time + window, independent of the near offer expiry"
+ );
+ assertGt(
+ dm.getSecondaryEscrow(settlementId).expiry,
+ offerValidUntil,
+ "settlement outlives the offer it was accepted against"
+ );
+ }
+
+ function test_AcceptOffer_SettlementExpiry_UsesConfiguredWindow() public {
+ vm.prank(owner);
+ dm.setSettlementWindow(45 days);
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ assertEq(
+ dm.getSecondaryEscrow(settlementId).expiry,
+ block.timestamp + 45 days,
+ "settlement expiry honors the configured window over the default"
+ );
+ }
+
+ // The compounding case from the finding: an acceptance late in the offer's life used to be
+ // unfinalizeable once block.timestamp passed offer.validUntil. With the window running from acceptance,
+ // the lot stays finalizeable well past the original offer expiry.
+ function test_FinalizeSecondaryTrade_LateAcceptance_StaysFinalizeable() public {
+ bytes32 offerId = _postSellOffer(); // validUntil = block.timestamp + 1 days
+ uint256 offerValidUntil = dm.getOffer(offerId).validUntil;
+
+ vm.warp(offerValidUntil - 1 minutes);
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ // Advance past the original offer expiry (but within the settlement window).
+ vm.warp(offerValidUntil + 1 days);
+ assertLt(block.timestamp, dm.getSecondaryEscrow(settlementId).expiry, "still inside the settlement window");
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "late-accepted lot finalizes past the original offer expiry"
+ );
+ }
+
+ // Voiding one of several lots on a FULLY_ACCEPTED offer leaves unitsAccepted > 0, so the offer
+ // reverts to PARTIALLY_ACCEPTED (not LIVE). The SELL lot returns to the offer's free pool and
+ // stays reserved (offer not cancelled); only the voided lot's buyer payment is refunded.
+ // Nonzero fee path: finalize pays the seller amount minus fee, and the fee splits into an
+ // integrator portion (to the offer's integrator/feeDestination) and a platform portion, summing
+ // to the total fee. Default tests run fee=0, so this is the only exercise of the split branch.
+ function test_FinalizeSecondaryTrade_NonzeroFee_SplitsIntegratorAndPlatform() public {
+ address integrator = makeAddr("integrator");
+ address platform = makeAddr("platform");
+
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 3000); // 30% of the fee routes to the integrator
+ vm.prank(owner);
+ dmFactory.setDefaultFeeRatio(1000); // 10% ticket fee
+ vm.prank(owner);
+ dmFactory.setPlatformPayable(platform);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_FinalizeSecondaryTrade_NonzeroFee_SplitsIntegratorAndPlatform"));
+ p.integrator = integrator;
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+
+ uint256 fee = CONSIDERATION * 1000 / 10000; // 1 ether
+ uint256 integratorFee = fee * 3000 / 10000; // 0.3 ether
+ uint256 platformFee = fee - integratorFee; // 0.7 ether
+
+ // The event reports the realized split: credited integrator (feeDestination) + the two portions.
+ vm.expectEmit(true, true, true, true);
+ emit ISecondaryTradeStorage.SecondaryFeeDistributed(
+ settlementId, address(paymentToken), integrator, fee, integratorFee, platformFee
+ );
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + CONSIDERATION - fee, "seller paid amount minus fee");
+ assertEq(paymentToken.balanceOf(integrator), integratorFee, "integrator gets its fee share");
+ assertEq(paymentToken.balanceOf(platform), platformFee, "platform gets the remaining fee");
+ assertEq(integratorFee + platformFee, fee, "split is exact: integrator + platform == total fee");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ }
+
+ // Spec §12B.4 fall-through: the integrator is validated at posting, but if it is removed from the
+ // factory whitelist before settlement the split must fall through to the unsplit MetaLeX-only flow
+ // (full fee to platform, integrator gets nothing) rather than revert — settlement is never blocked.
+ function test_FinalizeSecondaryTrade_DewhitelistedIntegrator_FallsThroughToPlatform() public {
+ address integrator = makeAddr("integrator");
+ address platform = makeAddr("platform");
+
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, true, 3000);
+ vm.prank(owner);
+ dmFactory.setDefaultFeeRatio(1000);
+ vm.prank(owner);
+ dmFactory.setPlatformPayable(platform);
+
+ PostOfferParams memory p = _defaultSellOfferParams();
+ p.salt = uint256(keccak256("test_FinalizeSecondaryTrade_DewhitelistedIntegrator_FallsThroughToPlatform"));
+ p.integrator = integrator;
+ vm.prank(seller);
+ bytes32 offerId = dm.postOffer(p);
+
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ // Integrator loses whitelist status after the binding contract has formed.
+ vm.prank(owner);
+ dmFactory.setIntegrator(integrator, false, 0);
+
+ uint256 sellerBefore = paymentToken.balanceOf(seller);
+
+ uint256 fee = CONSIDERATION * 1000 / 10000; // 1 ether
+
+ // Fall-through: feeDestination zero, no integrator share, full fee to the platform.
+ vm.expectEmit(true, true, true, true);
+ emit ISecondaryTradeStorage.SecondaryFeeDistributed(
+ settlementId, address(paymentToken), address(0), fee, 0, fee
+ );
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(paymentToken.balanceOf(seller), sellerBefore + CONSIDERATION - fee, "seller paid amount minus fee");
+ assertEq(paymentToken.balanceOf(integrator), 0, "de-whitelisted integrator gets nothing");
+ assertEq(paymentToken.balanceOf(platform), fee, "full fee falls through to platform");
+ assertEq(paymentToken.balanceOf(address(dm)), 0, "custody fully drained");
+ }
+
+ // Closing conditions are owner-managed DealManager config, snapshotted onto the offer at postOffer and
+ // evaluated at finalizeDeal — distinct from the threshold conditions checked at post/accept. A failing
+ // closing condition must block finalize.
+ function test_RevertIf_FinalizeSecondaryTrade_ClosingConditionFails() public {
+ // Register the closing condition before posting so it is snapshotted onto the offer.
+ // Deploy before vm.prank: the CREATE would otherwise consume the prank for addClosingCondition.
+ address failing = address(new SecConditionMock(false));
+ vm.prank(owner);
+ dm.addClosingCondition(failing);
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ vm.expectRevert(abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, failing));
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ // TODO rename and refactor legacy `FinalizeDeal_` tests
+ // Counterpart: a passing closing condition lets finalize through, proving the finalize-time
+ // check runs and is not an unconditional block.
+ function test_FinalizeSecondaryTrade_ClosingConditionPasses() public {
+ // Register the closing condition before posting so it is snapshotted onto the offer.
+ // Deploy before vm.prank: the CREATE would otherwise consume the prank for addClosingCondition.
+ address passing = address(new SecConditionMock(true));
+ vm.prank(owner);
+ dm.addClosingCondition(passing);
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "finalize proceeds when the closing condition passes"
+ );
+ }
+
+ // Threshold conditions are re-checked at finalization (spec §7.3.3): a buyer eligible at acceptance
+ // who loses eligibility before settlement must be blocked. The flippable condition passes through
+ // posting and acceptance, is flipped to fail, and the finalize call must revert.
+ function test_RevertIf_FinalizeSecondaryTrade_ThresholdConditionLapsesAfterAcceptance() public {
+ SecFlippableConditionMock cond = new SecFlippableConditionMock(true);
+ address[] memory conds = new address[](1);
+ conds[0] = address(cond);
+ _registerThresholdConditions(conds);
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId); // passes while eligible
+
+ cond.setPass(false); // eligibility lapses in the settlement window
+
+ vm.prank(keeper);
+ vm.expectRevert(
+ abi.encodeWithSelector(ISecondaryTradeStorage.SecondaryConditionsNotMet.selector, address(cond))
+ );
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ // Counterpart: a threshold condition that still passes at finalize lets the settlement close,
+ // proving the re-check runs and is not an unconditional block.
+ function test_FinalizeSecondaryTrade_ThresholdConditionStillPasses() public {
+ SecFlippableConditionMock cond = new SecFlippableConditionMock(true);
+ address[] memory conds = new address[](1);
+ conds[0] = address(cond);
+ _registerThresholdConditions(conds);
+
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(keeper);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+
+ assertEq(
+ uint8(dm.getSecondaryEscrow(settlementId).status),
+ uint8(SecondaryEscrowStatus.FINALIZED),
+ "finalize proceeds when the re-checked threshold condition still passes"
+ );
+ }
+
+ // Re-posting the same offeror/template/salt collides on the derived offerId and reverts.
+ function test_RevertIf_PostOffer_OfferAlreadyExists() public {
+ _postSellOffer();
+
+ vm.prank(seller);
+ vm.expectRevert(ISecondaryTradeStorage.OfferAlreadyExists.selector);
+ dm.postOffer(_defaultSellOfferParams()); // same salt → same offerId
+ }
+
+ // voidSecondaryTradeAgreement requires the declared signer to equal msg.sender.
+ function test_RevertIf_VoidSecondaryTradeAgreement_SignerNotCaller() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.prank(buyer);
+ vm.expectRevert(ISecondaryTradeStorage.NotSigner.selector);
+ dm.voidSecondaryTradeAgreement(settlementId, seller, ""); // signer=seller, caller=buyer
+ }
+
+ // A caller who is neither the offeror nor the settlement counterparty cannot request a void.
+ function test_RevertIf_VoidSecondaryTradeAgreement_NotPartyToAgreement() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ address stranger = makeAddr("stranger");
+ vm.prank(stranger);
+ vm.expectRevert(ISecondaryTradeStorage.NotPartyToAgreement.selector);
+ dm.voidSecondaryTradeAgreement(settlementId, stranger, "");
+ }
+
+ // finalizeSecondaryTradeAgreement past the settlement's expiry reverts. The settlement agreement's
+ // registry expiry equals secEscrow.expiry (both = acceptance time + settlement window), so the registry
+ // finalizeContract call reverts ContractExpired before the escrow's own SecondaryTradeAgreementExpired guard is
+ // reached — that guard is defensive/unreachable for secondary settlements in normal flow.
+ function test_RevertIf_FinalizeSecondaryTrade_PastSettlementExpiry() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ vm.warp(dm.getSecondaryEscrow(settlementId).expiry + 1);
+
+ vm.prank(keeper);
+ vm.expectRevert(CyberAgreementRegistry.ContractExpired.selector);
+ dm.finalizeSecondaryTradeAgreement(settlementId);
+ }
+
+ // Voiding the single partial lot of a PARTIALLY_ACCEPTED offer drops unitsAccepted to 0, so the
+ // offer returns to LIVE. The reservation is held (offer not cancelled) and the buyer is refunded.
+ // acceptOffer emits OfferAccepted with the offer id, acceptor, and units (settlement id is
+ // computed inside, so its topic is not checked).
+ function test_AcceptOffer_EmitsOfferAccepted() public {
+ bytes32 offerId = _postSellOffer();
+ // Compute the acceptor sig before vm.expectEmit/vm.prank: its registry view calls would
+ // otherwise consume the prank.
+ bytes memory sig = _acceptorSig(offerId, buyer, buyerKey);
+
+ AcceptOfferParams memory p = AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: sig,
+ openEndorsementSig: ""
+ });
+
+ // Check offerId + acceptor topics and the full data; skip only the settlement id topic, which is
+ // computed internally and can't be predicted here.
+ vm.expectEmit(true, false, true, true);
+ emit ISecondaryTradeStorage.OfferAccepted(
+ offerId,
+ bytes32(0),
+ buyer,
+ UNITS,
+ address(paymentToken),
+ CONSIDERATION,
+ sellerTokenId,
+ block.timestamp + 7 days,
+ SELL_ACCEPT_BUYER_NAME,
+ HostingMode.DIRECT,
+ address(0),
+ OPEN_ENDORSEMENT_SIG
+ );
+ vm.prank(buyer);
+ dm.acceptOffer(p);
+ }
+
+ // cancelOffer emits OfferCancelled with the offer id and offeror.
+ function test_CancelOffer_EmitsOfferCancelled() public {
+ bytes32 offerId = _postSellOffer();
+
+ vm.expectEmit(true, true, false, false);
+ emit ISecondaryTradeStorage.OfferCancelled(offerId, seller);
+ vm.prank(seller);
+ dm.cancelOffer(offerId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Relayer overloads — EIP-712 authorization on behalf of `forAddr`
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // The two param-type strings must byte-match SecondaryTradeStorage's typehash literals.
+ string constant _POST_PARAMS_TYPE =
+ "PostOfferParams(uint8 side,address certPrinter,uint256 tokenId,uint256 units,address paymentToken,uint256 consideration,uint8 exemptionPathway,uint256 validUntil,bytes counterpartyRestrictions,bytes additionalTerms,address integrator,bytes32 templateId,uint256 salt,string[] globalValues,string[] offerorPartyValues,bytes offerorAgreementSig,bytes openEndorsementSig,string buyerName,uint8 buyerHostingMode,address adminMultisig)";
+ string constant _ACCEPT_PARAMS_TYPE =
+ "AcceptOfferParams(bytes32 offerId,uint256 units,string buyerName,uint8 buyerHostingMode,address adminMultisig,uint256 sellerTokenId,string[] acceptorPartyValues,bytes acceptorAgreementSig,bytes openEndorsementSig)";
+
+ function _dmDomainSep() internal view returns (bytes32) {
+ return keccak256(abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes("DealManager")),
+ keccak256(bytes("1")),
+ block.chainid,
+ address(dm)
+ ));
+ }
+
+ function _hashStrs(string[] memory arr) internal pure returns (bytes32) {
+ bytes32[] memory h = new bytes32[](arr.length);
+ for (uint256 i = 0; i < arr.length; i++) h[i] = keccak256(bytes(arr[i]));
+ return keccak256(abi.encodePacked(h));
+ }
+
+ function _hashPostParams(PostOfferParams memory p) internal pure returns (bytes32) {
+ return keccak256(abi.encode(
+ keccak256(bytes(_POST_PARAMS_TYPE)),
+ uint8(p.side), p.certPrinter, p.tokenId, p.units, p.paymentToken, p.consideration,
+ uint8(p.exemptionPathway), p.validUntil, keccak256(p.counterpartyRestrictions),
+ keccak256(p.additionalTerms), p.integrator, p.templateId, p.salt,
+ _hashStrs(p.globalValues), _hashStrs(p.offerorPartyValues), keccak256(p.offerorAgreementSig),
+ keccak256(p.openEndorsementSig), keccak256(bytes(p.buyerName)), uint8(p.buyerHostingMode), p.adminMultisig
+ ));
+ }
+
+ function _hashAcceptParams(AcceptOfferParams memory p) internal pure returns (bytes32) {
+ return keccak256(abi.encode(
+ keccak256(bytes(_ACCEPT_PARAMS_TYPE)),
+ p.offerId, p.units, keccak256(bytes(p.buyerName)), uint8(p.buyerHostingMode),
+ p.adminMultisig, p.sellerTokenId, _hashStrs(p.acceptorPartyValues),
+ keccak256(p.acceptorAgreementSig), keccak256(p.openEndorsementSig)
+ ));
+ }
+
+ function _sign(bytes32 structHash, uint256 key) internal view returns (bytes memory) {
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _dmDomainSep(), structHash));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
+ return abi.encodePacked(r, s, v);
+ }
+
+ function _postAuthSig(PostOfferParams memory p, address forAddr, uint256 nonce, uint256 key)
+ internal view returns (bytes memory)
+ {
+ bytes32 typeHash = keccak256(bytes(string.concat(
+ "PostOfferAuth(PostOfferParams params,address forAddr,uint256 nonce)", _POST_PARAMS_TYPE)));
+ return _sign(keccak256(abi.encode(typeHash, _hashPostParams(p), forAddr, nonce)), key);
+ }
+
+ function _acceptAuthSig(AcceptOfferParams memory p, address forAddr, uint256 nonce, uint256 key)
+ internal view returns (bytes memory)
+ {
+ bytes32 typeHash = keccak256(bytes(string.concat(
+ "AcceptOfferAuth(AcceptOfferParams params,address forAddr,uint256 nonce)", _ACCEPT_PARAMS_TYPE)));
+ return _sign(keccak256(abi.encode(typeHash, _hashAcceptParams(p), forAddr, nonce)), key);
+ }
+
+ function _cancelAuthSig(bytes32 offerId, address forAddr, uint256 nonce, uint256 key)
+ internal view returns (bytes memory)
+ {
+ bytes32 typeHash = keccak256("CancelOfferAuth(bytes32 offerId,address forAddr,uint256 nonce)");
+ return _sign(keccak256(abi.encode(typeHash, offerId, forAddr, nonce)), key);
+ }
+
+ function _voidAuthSig(bytes32 agreementId, address signer, bytes memory signature, uint256 nonce, uint256 key)
+ internal view returns (bytes memory)
+ {
+ bytes32 typeHash = keccak256("VoidSecondaryTradeAuth(bytes32 agreementId,address signer,bytes32 signatureHash,uint256 nonce)");
+ return _sign(keccak256(abi.encode(typeHash, agreementId, signer, keccak256(signature), nonce)), key);
+ }
+
+ function _sellAcceptParams(bytes32 offerId) internal view returns (AcceptOfferParams memory) {
+ return AcceptOfferParams({
+ offerId: offerId,
+ units: UNITS,
+ buyerName: SELL_ACCEPT_BUYER_NAME,
+ buyerHostingMode: HostingMode.DIRECT,
+ adminMultisig: address(0),
+ sellerTokenId: 0,
+ acceptorPartyValues: new string[](0),
+ acceptorAgreementSig: _acceptorSig(offerId, buyer, buyerKey),
+ openEndorsementSig: ""
+ });
+ }
+
+ // A relayer posts a SELL offer for the seller: identity + reservation attribute to `forAddr`.
+ function test_PostOffer_Relayer_SellAttributesToForAddr() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p = _defaultSellOfferParams();
+ bytes memory sig = _postAuthSig(p, seller, 7, sellerKey);
+
+ vm.prank(relayer);
+ bytes32 offerId = dm.postOffer(p, seller, 7, sig);
+
+ assertEq(dm.getOffer(offerId).offeror, seller);
+ assertEq(offerId, keccak256(abi.encode(seller, p.templateId, p.salt)));
+ assertEq(certPrinter.unitsReserved(sellerTokenId), UNITS);
+ }
+
+ // A relayer posts a BUY offer for the buyer: consideration is pulled from `forAddr`, not the relayer
+ // (the relayer holds no tokens/allowance, so success proves the pull came from `forAddr`).
+ function test_PostOffer_Relayer_BuyPullsFromForAddr() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p = _defaultBuyOfferParams();
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+ bytes memory sig = _postAuthSig(p, buyer, 1, buyerKey);
+
+ vm.prank(relayer);
+ bytes32 offerId = dm.postOffer(p, buyer, 1, sig);
+
+ assertEq(dm.getOffer(offerId).offeror, buyer);
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore - CONSIDERATION);
+ assertEq(paymentToken.balanceOf(address(dm)), CONSIDERATION);
+ }
+
+ // A relayer cancels the seller's offer: status flips and the reservation is released.
+ function test_CancelOffer_Relayer() public {
+ bytes32 offerId = _postSellOffer();
+ address relayer = makeAddr("relayer");
+ bytes memory sig = _cancelAuthSig(offerId, seller, 3, sellerKey);
+
+ vm.prank(relayer);
+ dm.cancelOffer(offerId, seller, 3, sig);
+
+ assertEq(uint8(dm.getOffer(offerId).status), uint8(OfferStatus.CANCELLED));
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 0);
+ }
+
+ // A relayer accepts a SELL offer for the buyer: escrow counterparty is `forAddr` and payment is pulled
+ // from `forAddr`.
+ function test_AcceptOffer_Relayer_AttributesToForAddr() public {
+ bytes32 offerId = _postSellOffer();
+ address relayer = makeAddr("relayer");
+ AcceptOfferParams memory p = _sellAcceptParams(offerId);
+ uint256 buyerBefore = paymentToken.balanceOf(buyer);
+ bytes memory sig = _acceptAuthSig(p, buyer, 9, buyerKey);
+
+ vm.prank(relayer);
+ bytes32 settlementId = dm.acceptOffer(p, buyer, 9, sig);
+
+ assertEq(dm.getSecondaryEscrow(settlementId).counterparty, buyer);
+ assertEq(paymentToken.balanceOf(buyer), buyerBefore - CONSIDERATION);
+ }
+
+ // A signature by the wrong key does not recover to `forAddr`.
+ function test_RevertIf_Relayer_WrongSigner() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p = _defaultSellOfferParams();
+ bytes memory sig = _postAuthSig(p, seller, 1, buyerKey); // signed by buyer, not seller
+
+ vm.prank(relayer);
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryAuthSignature.selector);
+ dm.postOffer(p, seller, 1, sig);
+ }
+
+ // Tampering with an authorized field (here `forAddr`) breaks recovery.
+ function test_RevertIf_Relayer_TamperedForAddr() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p = _defaultSellOfferParams();
+ bytes memory sig = _postAuthSig(p, seller, 1, sellerKey);
+
+ vm.prank(relayer);
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryAuthSignature.selector);
+ dm.postOffer(p, buyer, 1, sig); // forAddr swapped to buyer; sig was over seller
+ }
+
+ // Reusing a consumed nonce is rejected at the auth layer (before any downstream state check).
+ function test_RevertIf_Relayer_ReplayNonce() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p = _defaultSellOfferParams();
+ bytes memory sig = _postAuthSig(p, seller, 5, sellerKey);
+
+ vm.prank(relayer);
+ dm.postOffer(p, seller, 5, sig);
+
+ vm.prank(relayer);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryAuthReplayed.selector);
+ dm.postOffer(p, seller, 5, sig);
+ }
+
+ // Unordered nonces: two authorizations with different, out-of-order nonces both succeed.
+ function test_Relayer_UnorderedNonces() public {
+ address relayer = makeAddr("relayer");
+ PostOfferParams memory p1 = _defaultSellOfferParams();
+ p1.salt = uint256(keccak256("relayerUnordered1"));
+ p1.units = 30;
+ PostOfferParams memory p2 = _defaultSellOfferParams();
+ p2.salt = uint256(keccak256("relayerUnordered2"));
+ p2.units = 30;
+ bytes memory sig1 = _postAuthSig(p1, seller, 100, sellerKey);
+ bytes memory sig2 = _postAuthSig(p2, seller, 5, sellerKey);
+
+ vm.prank(relayer);
+ dm.postOffer(p1, seller, 100, sig1);
+ vm.prank(relayer);
+ dm.postOffer(p2, seller, 5, sig2);
+
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 60);
+ }
+
+ // The direct and relayer postOffer overloads present distinct selectors to conditions. A phase-gating
+ // threshold condition registers both, so it is satisfied whether the offer is posted directly or via the
+ // relayer overload — the real-world pattern documented on ISecondaryTradingCondition.
+ function test_ThresholdCondition_HandlesBothPostOfferOverloads() public {
+ bytes4[] memory sels = new bytes4[](2);
+ sels[0] = bytes4(keccak256("postOffer(PostOfferParams)")); // direct
+ sels[1] = bytes4(keccak256("postOffer(PostOfferParams,address,uint256,bytes)")); // relayer
+ SecSelectorAssertingConditionMock cond = new SecSelectorAssertingConditionMock(sels);
+ vm.prank(owner);
+ dm.addSpvThresholdCondition(address(cond));
+
+ // direct path
+ PostOfferParams memory pd = _defaultSellOfferParams();
+ pd.salt = uint256(keccak256("bothOverloadsDirect"));
+ pd.units = 30;
+ vm.prank(seller);
+ dm.postOffer(pd);
+
+ // relayer path
+ PostOfferParams memory pr = _defaultSellOfferParams();
+ pr.salt = uint256(keccak256("bothOverloadsRelayer"));
+ pr.units = 30;
+ bytes memory sig = _postAuthSig(pr, seller, 2, sellerKey);
+ vm.prank(makeAddr("relayer"));
+ dm.postOffer(pr, seller, 2, sig);
+
+ // both offers cleared the same phase condition
+ assertEq(certPrinter.unitsReserved(sellerTokenId), 60);
+ }
+
+ // A relayer submits each party's void request on their behalf via the nonce'd overload; once both
+ // parties have requested, the settlement is voided (a subsequent void reverts AlreadyVoided).
+ function test_Relayer_VoidSecondaryTradeAgreement() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+ address relayer = makeAddr("relayer");
+
+ bytes memory buyerAuth = _voidAuthSig(settlementId, buyer, "", 11, buyerKey);
+ vm.prank(relayer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "", 11, buyerAuth);
+
+ bytes memory sellerAuth = _voidAuthSig(settlementId, seller, "", 12, sellerKey);
+ vm.prank(relayer);
+ dm.voidSecondaryTradeAgreement(settlementId, seller, "", 12, sellerAuth);
+
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryTradeAgreementAlreadyVoided.selector);
+ vm.prank(buyer);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "");
+ }
+
+ // The relayer void auth must recover to `signer`; a signature by anyone else is rejected.
+ function test_RevertIf_Relayer_VoidSecondaryTradeAgreement_WrongSigner() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ bytes memory badAuth = _voidAuthSig(settlementId, buyer, "", 1, sellerKey); // signed by seller, claims buyer
+ vm.expectRevert(ISecondaryTradeStorage.InvalidSecondaryAuthSignature.selector);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "", 1, badAuth);
+ }
+
+ // Reusing a consumed nonce for the same signer is rejected as a replay.
+ function test_RevertIf_Relayer_VoidSecondaryTradeAgreement_ReplayNonce() public {
+ bytes32 offerId = _postSellOffer();
+ bytes32 settlementId = _acceptSellOffer(offerId);
+
+ bytes memory auth = _voidAuthSig(settlementId, buyer, "", 4, buyerKey);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "", 4, auth);
+
+ bytes memory replay = _voidAuthSig(settlementId, buyer, "", 4, buyerKey);
+ vm.expectRevert(ISecondaryTradeStorage.SecondaryAuthReplayed.selector);
+ dm.voidSecondaryTradeAgreement(settlementId, buyer, "", 4, replay);
+ }
+}
diff --git a/test/DealManagerTest.t.sol b/test/DealManagerTest.t.sol
index 45e06b3e..55ef3369 100644
--- a/test/DealManagerTest.t.sol
+++ b/test/DealManagerTest.t.sol
@@ -48,7 +48,9 @@ import {ERC721Enumerable} from "../dependencies/openzeppelin-contracts/contracts
import {ERC1967Proxy} from "../dependencies/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC1967} from "../dependencies/openzeppelin-contracts/contracts/interfaces/IERC1967.sol";
import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol";
-import {DealManager, LexScroWLite} from "../src/DealManager.sol";
+import {DealManager, LexScrowStorage} from "../src/DealManager.sol";
+import {IDealManager} from "../src/interfaces/IDealManager.sol";
+import {IDealManagerStorage} from "../src/interfaces/IDealManagerStorage.sol";
import {DealManagerFactory} from "../src/DealManagerFactory.sol";
import {BorgAuth} from "../src/libs/auth.sol";
import {CertificateDetails, Endorsement} from "../src/storage/CyberCertPrinterStorage.sol";
@@ -69,6 +71,10 @@ contract IssuanceManagerMock {
) external returns (uint256) {
return CyberCertPrinterMock(certAddress).mint(to);
}
+
+ function voidCertificate(address certAddress, uint256 tokenId) external {
+ CyberCertPrinterMock(certAddress).burn(tokenId);
+ }
}
contract CyberCertPrinterMock is ERC721Enumerable {
@@ -89,6 +95,10 @@ contract CyberCertPrinterMock is ERC721Enumerable {
function getEndorsementHistory(uint256 tokenId, uint256 index) external view returns (Endorsement memory) {
return endorsements[tokenId][index];
}
+
+ function burn(uint256 tokenId) external {
+ _burn(tokenId);
+ }
}
contract CyberAgreementRegistryMock {
@@ -364,7 +374,7 @@ contract DealManagerTest is Test {
function test_PaymentFlow_ProposeDeal() public {
// proposeDeal() is one of the two methods that'll pull certificates from the issuing company (first party)
- // Unlike the more generic LexScroWLite, DealManager assumes the company's assets are certificates-only
+ // Unlike the more generic LexScrowStorage, DealManager assumes the company's assets are certificates-only
// After the transaction, the company's certificates should be in escrow.
// Deal configs
@@ -398,7 +408,7 @@ contract DealManagerTest is Test {
function test_PaymentFlow_ProposeAndSignDeal() public {
// proposeAndSignDeal() is one of the two methods that'll pull certificates from the issuing company (first party)
- // Unlike the more generic LexScroWLite, DealManager assumes the company's assets are certificates-only
+ // Unlike the more generic LexScrowStorage, DealManager assumes the company's assets are certificates-only
// The signature must be valid.
// After the transaction, the company's certificates should be in escrow.
@@ -538,7 +548,7 @@ contract DealManagerTest is Test {
);
vm.expectEmit(true, true, true, true);
- emit DealManager.DealFinalized(
+ emit IDealManagerStorage.DealFinalized(
agreementId,
alice,
address(corp),
@@ -571,7 +581,7 @@ contract DealManagerTest is Test {
);
vm.expectEmit(true, true, true, true);
- emit DealManager.DealFinalized(
+ emit IDealManagerStorage.DealFinalized(
agreementId,
bob,
address(corp),
@@ -786,10 +796,81 @@ contract DealManagerTest is Test {
);
// Refund should fail because the deal is not voided
- vm.expectRevert(LexScroWLite.DealNotVoided.selector);
+ vm.expectRevert(LexScrowStorage.DealNotVoided.selector);
dm.refundVoidedDeal(agreementId);
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // Id-space validation: primary entrypoints require a primary escrow. An id with no primary escrow
+ // (an unknown id, or a secondary-trade settlement — which never creates a LexScrow escrow) reverts
+ // DealDoesNotExist. The guard runs first, so caller/state checks are not reached.
+ // ─────────────────────────────────────────────────────────────────────────
+
+ function test_RevertIf_FinalizeDeal_UnknownDeal() public {
+ vm.expectRevert(LexScrowStorage.DealDoesNotExist.selector);
+ dm.finalizeDeal(keccak256("unknown-deal"));
+ }
+
+ function test_RevertIf_VoidExpiredDeal_UnknownDeal() public {
+ vm.expectRevert(LexScrowStorage.DealDoesNotExist.selector);
+ dm.voidExpiredDeal(keccak256("unknown-deal"), alice, "");
+ }
+
+ function test_VoidExpiredDeal_VoidsCert() public {
+ string[][] memory partyValues = new string[][](2);
+ partyValues[0] = new string[](0);
+ partyValues[1] = new string[](0);
+
+ uint256 expiry = block.timestamp + 1 days;
+
+ vm.prank(owner);
+ (bytes32 agreementId, uint256[] memory certIds) = dm.proposeAndSignDeal(
+ defaultCertPrinters,
+ address(paymentToken),
+ 10 ether, // paymentAmount
+ 0, // templateId
+ uint256(keccak256("DealManagerTest.Deal")),
+ new string[](0), // globalValues
+ defaultParties,
+ defaultCertDetails,
+ companyOwner, // proposer
+ GOOD_SIGNATURE, // signature
+ partyValues,
+ new address[](0), // TODO conditions
+ bytes32(0), // secretHash
+ expiry
+ );
+
+ // Cert is now in escrow (owned by DealManager)
+ assertEq(CyberCertPrinterMock(defaultCertPrinters[0]).ownerOf(certIds[0]), address(dm));
+
+ vm.warp(expiry + 1);
+
+ // signer must be a party to the agreement (companyOwner); DealManager is the finalizer so no void sig is needed
+ dm.voidExpiredDeal(agreementId, companyOwner, "");
+
+ // Cert should be burned (voided) via IssuanceManager.voidCertificate
+ vm.expectRevert();
+ CyberCertPrinterMock(defaultCertPrinters[0]).ownerOf(certIds[0]); // burned token should revert on ownerOf
+ }
+
+ function test_RevertIf_RevokeDeal_UnknownDeal() public {
+ vm.prank(alice);
+ vm.expectRevert(LexScrowStorage.DealDoesNotExist.selector);
+ dm.revokeDeal(keccak256("unknown-deal"), alice, "");
+ }
+
+ function test_RevertIf_SignToVoid_UnknownDeal() public {
+ vm.prank(alice);
+ vm.expectRevert(LexScrowStorage.DealDoesNotExist.selector);
+ dm.signToVoid(keccak256("unknown-deal"), alice, "");
+ }
+
+ function test_RevertIf_RefundVoidedDeal_UnknownDeal() public {
+ vm.expectRevert(LexScrowStorage.DealDoesNotExist.selector);
+ dm.refundVoidedDeal(keccak256("unknown-deal"));
+ }
+
function test_UpgradeNextDealManager() public {
vm.startPrank(owner);
DealManagerFactory(dmFactory).setRefImplementation(address(new MockDealManagerVTest()));
diff --git a/test/IssuanceManagerConversionTest.t.sol b/test/IssuanceManagerConversionTest.t.sol
index e355024a..156992bd 100644
--- a/test/IssuanceManagerConversionTest.t.sol
+++ b/test/IssuanceManagerConversionTest.t.sol
@@ -14,6 +14,7 @@ import "../src/libs/auth.sol";
import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
import {IssuanceManager} from "../src/IssuanceManager.sol";
+import {RestrictiveLegend} from "../src/storage/CyberCertPrinterStorage.sol";
contract MockRoundManagerForConversion {
bool public exists;
@@ -220,6 +221,27 @@ contract MockUriBuilder is IUriBuilder {
return "uri://mock";
}
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return "uri://mock";
+ }
+
function buildCertificateUriNotEncoded(
string memory,
string memory,
@@ -240,6 +262,27 @@ contract MockUriBuilder is IUriBuilder {
) external pure returns (string memory) {
return "uri://mock";
}
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return "uri://mock";
+ }
}
contract IssuanceManagerConversionTest is Test {
diff --git a/test/IssuanceManagerRecertTokenUriRegression.t.sol b/test/IssuanceManagerRecertTokenUriRegression.t.sol
index 10b4b77a..883480a4 100644
--- a/test/IssuanceManagerRecertTokenUriRegression.t.sol
+++ b/test/IssuanceManagerRecertTokenUriRegression.t.sol
@@ -7,6 +7,7 @@ import {
CertificateDetails,
Endorsement,
OwnerDetails,
+ RestrictiveLegend,
SecurityClass,
SecuritySeries
} from "../src/storage/CyberCertPrinterStorage.sol";
@@ -44,6 +45,32 @@ contract UriBuilderWithUnits is IUriBuilder {
);
}
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory details,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return
+ string.concat(
+ '{"unitsRepresented":"',
+ Strings.toString(details.unitsRepresented / 1e18),
+ '"}'
+ );
+ }
+
function buildCertificateUriNotEncoded(
string memory,
string memory,
@@ -69,6 +96,32 @@ contract UriBuilderWithUnits is IUriBuilder {
'"}'
);
}
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory details,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return
+ string.concat(
+ '{"unitsRepresented":"',
+ Strings.toString(details.unitsRepresented / 1e18),
+ '"}'
+ );
+ }
}
contract IssuanceManagerRecertTokenUriRegressionTest is
diff --git a/test/IssuanceManagerScripComplianceTest.t.sol b/test/IssuanceManagerScripComplianceTest.t.sol
index 6193427b..b5b2623e 100644
--- a/test/IssuanceManagerScripComplianceTest.t.sol
+++ b/test/IssuanceManagerScripComplianceTest.t.sol
@@ -18,6 +18,7 @@ contract MockCertPrinterBasic {
string private _symbol;
mapping(uint256 => address) private _owners;
mapping(uint256 => CertificateDetails) private _details;
+ mapping(uint256 => uint256) private _reserved;
constructor(string memory name_, string memory symbol_) {
_name = name_;
@@ -32,7 +33,10 @@ contract MockCertPrinterBasic {
_details[id].unitsRepresented = units;
}
+ function mockSetReserved(uint256 id, uint256 units) external { _reserved[id] = units; }
+
function isVoided(uint256) external pure returns (bool) { return false; }
+ function unitsReserved(uint256 id) external view returns (uint256) { return _reserved[id]; }
function legalOwnerOf(uint256 id) external view returns (address) { return _owners[id]; }
function getActiveCertificateDetails(uint256 id) external view returns (CertificateDetails memory) { return _details[id]; }
function updateCertificateDetails(uint256 id, CertificateDetails calldata det) external { _details[id] = det; }
@@ -115,6 +119,26 @@ contract IssuanceManagerScripComplianceTest is Test {
issuanceManager.scripifyCert(address(cert), 0, 100 ether, address(0));
}
+ function test_scripifyCert_revertsWhenAmountExceedsFreeUnits() public {
+ cert.mockMintCert(1, user1, 100 ether);
+ cert.mockSetReserved(1, 60 ether); // only 40 free
+
+ vm.prank(user1);
+ vm.expectRevert(abi.encodeWithSignature("AmountExceedsAvailableUnits()"));
+ issuanceManager.scripifyCert(address(cert), 1, 41 ether, address(0));
+ }
+
+ function test_scripifyCert_succeedsUpToFreeUnits() public {
+ cert.mockMintCert(2, user1, 100 ether);
+ cert.mockSetReserved(2, 60 ether); // 40 free
+
+ vm.prank(user1);
+ issuanceManager.scripifyCert(address(cert), 2, 40 ether, address(0));
+
+ CertificateDetails memory det = cert.getActiveCertificateDetails(2);
+ assertEq(det.unitsRepresented, 60 ether);
+ }
+
function test_setScripRestrictionHooks_updatesHook() public {
ITransferRestrictionHook[] memory newHooks = new ITransferRestrictionHook[](1);
newHooks[0] = ITransferRestrictionHook(address(denyHook));
diff --git a/test/IssuanceManagerSecondaryTransferTest.t.sol b/test/IssuanceManagerSecondaryTransferTest.t.sol
new file mode 100644
index 00000000..392fd483
--- /dev/null
+++ b/test/IssuanceManagerSecondaryTransferTest.t.sol
@@ -0,0 +1,380 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity ^0.8.13;
+
+import "../src/CyberCertPrinter.sol";
+import "../src/CyberScrip.sol";
+import "../src/IssuanceManager.sol";
+import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
+import "../src/interfaces/ICyberCertPrinter.sol";
+import {IIssuanceManager} from "../src/interfaces/IIssuanceManager.sol";
+import {ITransferRestrictionHook} from "../src/interfaces/ITransferRestrictionHook.sol";
+import {ICondition} from "../src/interfaces/ICondition.sol";
+import {ExemptionPathway, HostingMode} from "../src/interfaces/ISecondaryTradeStorage.sol";
+import "../src/libs/auth.sol";
+import "forge-std/Test.sol";
+import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol";
+// Reuse the minimal real-contract fixture mocks rather than redeclaring them.
+import {
+ MockCyberCorpForIM,
+ MockUriBuilderForIM
+} from "./IssuanceManagerTest.t.sol";
+
+/// @title IssuanceManagerSecondaryTransferTest
+/// @notice Exercises the real IssuanceManager.secondaryTransfer against a real CyberCertPrinter (no mocks),
+/// proving the mutate-and-mint ownership change, the seller-token and buyer-token endorsements materialized at
+/// finalization, and the SecondaryTransferExecuted signal an indexer reads for the buyer's new token id.
+contract IssuanceManagerSecondaryTransferTest is Test {
+ bytes32 constant SALT = bytes32(keccak256("IssuanceManagerSecondaryTransferTest"));
+
+ IssuanceManager public issuanceManager;
+ BorgAuth public auth;
+
+ address public owner;
+ address public seller;
+ address public buyer;
+
+ uint256 constant UNITS = 100;
+ bytes constant SELLER_SIG = hex"deadbeef";
+ bytes32 constant SETTLEMENT_ID = keccak256("settlement");
+
+ function setUp() public {
+ owner = address(this);
+ seller = makeAddr("seller");
+ buyer = makeAddr("buyer");
+
+ auth = new BorgAuth(owner);
+
+ IssuanceManagerFactory imFactory = IssuanceManagerFactory(
+ address(
+ new ERC1967Proxy{salt: SALT}(
+ address(new IssuanceManagerFactory{salt: SALT}()),
+ abi.encodeWithSelector(
+ IssuanceManagerFactory.initialize.selector,
+ address(auth),
+ new IssuanceManager(),
+ new CyberCertPrinter(),
+ new CyberScrip()
+ )
+ )
+ )
+ );
+
+ issuanceManager = IssuanceManager(imFactory.deployIssuanceManager(SALT));
+ issuanceManager.initialize(
+ address(auth),
+ address(new MockCyberCorpForIM()),
+ address(new MockUriBuilderForIM()),
+ address(imFactory)
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenarios
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Full sale: the entire seller position is sold, so the seller's Ledger Entry Token is voided and the
+ // buyer's new token represents all the units.
+ function test_SecondaryTransfer_FullSale_VoidsSellerMintsBuyer() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+
+ // The buyer's new token is the next minted id (seller cert is tokenId 0).
+ uint256 expectedBuyerTokenId = 1;
+ vm.expectEmit(true, true, true, true, address(issuanceManager));
+ emit IIssuanceManager.SecondaryTransferExecuted(
+ SETTLEMENT_ID, address(cert), buyer, 0, expectedBuyerTokenId, seller, UNITS, 0, UNITS, true, true
+ );
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, UNITS, "Bob", HostingMode.DIRECT, address(0)));
+
+ // Seller side: token voided, real endorsement materialized.
+ assertTrue(cert.isVoided(0), "seller cert voided on full sale");
+ _assertSellerEndorsement(cert, 0, "Bob");
+
+ // Buyer side: new token minted to the buyer for all units.
+ assertEq(cert.legalOwnerOf(expectedBuyerTokenId), buyer, "buyer is registered owner");
+ assertEq(cert.getCertificateDetails(expectedBuyerTokenId).unitsRepresented, UNITS, "buyer units");
+ // A secondary-acquired cert carries no primary-issuance basis: those fields are blank.
+ assertEq(cert.getCertificateDetails(expectedBuyerTokenId).investmentAmountUSD, 0, "buyer cost basis blank");
+ assertEq(
+ cert.getCertificateDetails(expectedBuyerTokenId).issuerUSDValuationAtTimeOfInvestment,
+ 0,
+ "buyer valuation blank"
+ );
+ _assertMirrorEndorsement(cert, expectedBuyerTokenId, "Bob");
+ }
+
+ // Partial sale: only part of the seller position is sold, so the seller's token is decremented in place
+ // (not voided) and the buyer's new token represents only the sold units.
+ function test_SecondaryTransfer_PartialSale_DecrementsSeller() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+ uint256 soldUnits = 40;
+
+ uint256 expectedBuyerTokenId = 1;
+ vm.expectEmit(true, true, true, true, address(issuanceManager));
+ emit IIssuanceManager.SecondaryTransferExecuted(
+ SETTLEMENT_ID, address(cert), buyer, 0, expectedBuyerTokenId, seller, soldUnits, UNITS - soldUnits, soldUnits, false, true
+ );
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, soldUnits, "Bob", HostingMode.DIRECT, address(0)));
+
+ // Seller side: not voided, units decremented by the sold lot.
+ assertFalse(cert.isVoided(0), "seller cert survives a partial sale");
+ assertEq(cert.getCertificateDetails(0).unitsRepresented, UNITS - soldUnits, "seller remainder");
+ // The seller's cert keeps its primary-issuance basis snapshot; only units decrement.
+ assertEq(cert.getCertificateDetails(0).investmentAmountUSD, 1000, "seller cost basis unchanged");
+ assertEq(
+ cert.getCertificateDetails(0).issuerUSDValuationAtTimeOfInvestment,
+ 10000,
+ "seller valuation unchanged"
+ );
+ _assertSellerEndorsement(cert, 0, "Bob");
+
+ // Buyer side: new token for the sold units only, with blank cost basis (secondary acquisition).
+ assertEq(cert.legalOwnerOf(expectedBuyerTokenId), buyer, "buyer is registered owner");
+ assertEq(cert.getCertificateDetails(expectedBuyerTokenId).unitsRepresented, soldUnits, "buyer units");
+ assertEq(cert.getCertificateDetails(expectedBuyerTokenId).investmentAmountUSD, 0, "buyer cost basis blank");
+ assertEq(
+ cert.getCertificateDetails(expectedBuyerTokenId).issuerUSDValuationAtTimeOfInvestment,
+ 0,
+ "buyer valuation blank"
+ );
+ _assertMirrorEndorsement(cert, expectedBuyerTokenId, "Bob");
+ }
+
+ // A scripified seller token must decrement its RAW (in-cert) units, not the effective balance that folds
+ // scripified units back in. Reading effective and writing it straight back would corrupt the stored raw
+ // count, double-counting the scripified portion on the next read.
+ function test_SecondaryTransfer_ScripifiedSellerToken_DecrementsRawUnits() public {
+ ICyberCertPrinter cert = ICyberCertPrinter(
+ issuanceManager.createCertPrinter(
+ new string[](0), "Cert", "CERT", "uri://cert",
+ SecurityClass.CommonStock, SecuritySeries.SeriesA, address(0)
+ )
+ );
+ CertificateDetails memory details = CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 1000,
+ issuerUSDValuationAtTimeOfInvestment: 10000,
+ unitsRepresented: 100,
+ legalDetails: "",
+ extensionData: bytes("")
+ });
+ issuanceManager.createCertAndAssign(address(cert), seller, details);
+
+ // Scripify 30 of the 100 units: raw -> 70, scripified 30, effective stays 100.
+ _deployScrip(address(cert));
+ vm.prank(seller);
+ issuanceManager.scripifyCert(address(cert), 0, 30, address(0));
+ assertEq(cert.getActiveCertificateDetails(0).unitsRepresented, 70, "raw after scripify");
+ assertEq(cert.getCertificateDetails(0).unitsRepresented, 100, "effective after scripify");
+
+ // Sell 40 of the raw 70.
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, 40, "Bob", HostingMode.DIRECT, address(0)));
+
+ // Seller raw drops by exactly the sold units (70 -> 30); the scripified portion is untouched, so the
+ // effective view is 30 + 30. An effective read-write would have stored 60 raw (effective 90) — corruption.
+ assertEq(cert.getActiveCertificateDetails(0).unitsRepresented, 30, "seller raw should decrement and not include scripified units");
+ assertEq(cert.getCertificateDetails(0).unitsRepresented, 60, "seller effective = raw + scripified");
+ assertFalse(cert.isVoided(0), "partial sale keeps seller token active");
+
+ // Buyer gets a fresh token for the sold units only.
+ assertEq(cert.getActiveCertificateDetails(1).unitsRepresented, 40, "buyer raw units");
+ assertEq(cert.legalOwnerOf(1), buyer, "buyer owns new token");
+ }
+
+ // Administered hosting (HostingMode.ADMINISTERED) custodies the new token with the admin multisig, but the
+ // buyer is still the registered legal owner of record (spec §7.4A) — custody and ownership are distinct.
+ function test_SecondaryTransfer_AdministeredHosting_MultisigCustodiesBuyerOwns() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+ address adminMultisig = makeAddr("adminMultisig");
+
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, UNITS, "Bob", HostingMode.ADMINISTERED, adminMultisig));
+
+ // Custody: the multisig holds the NFT. Ownership of record: the buyer, not the custodian.
+ assertEq(cert.ownerOf(1), adminMultisig, "administered token custodied by multisig");
+ assertEq(cert.legalOwnerOf(1), buyer, "buyer is the registered legal owner under administered hosting");
+ assertEq(cert.balanceOf(buyer), 0, "buyer does not custody the NFT");
+ _assertMirrorEndorsement(cert, 1, "Bob");
+ }
+
+ // A buyer's repeat purchase consolidates by default: a second secondary transfer folds its units into the
+ // buyer's existing active cert rather than minting a new one (mirrors the scrip-to-cert recert behavior).
+ function test_SecondaryTransfer_RepeatPurchase_AccumulatesIntoExistingCert() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+
+ // First purchase: 40 units mints the buyer a fresh cert (id 1).
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, 40, "Bob", HostingMode.DIRECT, address(0)));
+ assertEq(cert.balanceOf(buyer), 1, "buyer holds one cert after the first purchase");
+ assertEq(cert.getCertificateDetails(1).unitsRepresented, 40, "first cert holds 40");
+
+ // Second purchase: another 40 units reuses cert 1 (the event reports the existing token, not a new mint).
+ vm.expectEmit(true, true, true, true, address(issuanceManager));
+ emit IIssuanceManager.SecondaryTransferExecuted(SETTLEMENT_ID, address(cert), buyer, 0, 1, seller, 40, 20, 80, false, false);
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, 40, "Bob", HostingMode.DIRECT, address(0)));
+
+ assertEq(cert.totalSupply(), 2, "no new cert minted (seller 0 + buyer 1)");
+ assertEq(cert.balanceOf(buyer), 1, "buyer still holds a single consolidated cert");
+ assertEq(cert.getCertificateDetails(1).unitsRepresented, 80, "units accumulated into the existing cert");
+ }
+
+ // Merging a secondary purchase into a cert the buyer already holds from PRIMARY issuance must leave that
+ // cert's cost-basis snapshot untouched — only units accumulate (spec: snapshot at primary issuance).
+ function test_SecondaryTransfer_MergeIntoPrimaryCert_PreservesBasisSnapshot() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+
+ // The buyer already holds a primary-issued cert (id 1) with its own cost basis.
+ CertificateDetails memory primaryDetails = CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 2000,
+ issuerUSDValuationAtTimeOfInvestment: 20000,
+ unitsRepresented: 50,
+ legalDetails: "",
+ extensionData: bytes("")
+ });
+ issuanceManager.createCertAndAssign(address(cert), buyer, primaryDetails);
+
+ // A secondary purchase of 30 units folds into the buyer's existing primary cert (id 1).
+ issuanceManager.secondaryTransfer(_dealMetadata(address(cert), 0, 30, "Bob", HostingMode.DIRECT, address(0)));
+
+ CertificateDetails memory merged = cert.getCertificateDetails(1);
+ assertEq(merged.unitsRepresented, 80, "secondary units folded into the primary cert");
+ assertEq(merged.investmentAmountUSD, 2000, "primary cost-basis snapshot unchanged");
+ assertEq(merged.issuerUSDValuationAtTimeOfInvestment, 20000, "primary valuation snapshot unchanged");
+ }
+
+ // TODO should test administered hosted as well
+
+ // Administered hosting where ONE multisig custodies certs for two different legal owners. Consolidation must
+ // target the buyer's OWN cert by legal owner of record — never another holder's cert that happens to share
+ // the custodian. This is the case a custody (balanceOf) scan could not get right.
+ function test_SecondaryTransfer_AdministeredHosting_AccumulatesByLegalOwnerNotCustodian() public {
+ ICyberCertPrinter cert = _deployPrinterWithSellerCert(UNITS);
+ address adminMultisig = makeAddr("adminMultisig");
+ (address buyer2,) = makeAddrAndKey("buyer2");
+
+ // The same multisig custodies both buyers' certs.
+ issuanceManager.secondaryTransfer(_dealMetadataFor(buyer, address(cert), 0, 30, "Bob", HostingMode.ADMINISTERED, adminMultisig));
+ issuanceManager.secondaryTransfer(_dealMetadataFor(buyer2, address(cert), 0, 40, "Carol", HostingMode.ADMINISTERED, adminMultisig));
+
+ // Bob buys again: must accumulate into Bob's cert (id 1), not Carol's (id 2).
+ issuanceManager.secondaryTransfer(_dealMetadataFor(buyer, address(cert), 0, 30, "Bob", HostingMode.ADMINISTERED, adminMultisig));
+
+ assertEq(cert.balanceOf(adminMultisig), 2, "multisig custodies one cert per legal owner");
+ assertEq(cert.legalOwnerOf(1), buyer, "cert 1 owned of record by Bob");
+ assertEq(cert.legalOwnerOf(2), buyer2, "cert 2 owned of record by Carol");
+ assertEq(cert.getCertificateDetails(1).unitsRepresented, 60, "Bob's cert accumulated both his fills");
+ assertEq(cert.getCertificateDetails(2).unitsRepresented, 40, "Carol's cert untouched");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// @dev Deploys a printer and mints the seller's Ledger Entry Token (id 0, `units` units)
+ function _deployPrinterWithSellerCert(uint256 units) internal returns (ICyberCertPrinter cert) {
+ cert = ICyberCertPrinter(
+ issuanceManager.createCertPrinter(
+ new string[](0),
+ "Cert",
+ "CERT",
+ "uri://cert",
+ SecurityClass.CommonStock,
+ SecuritySeries.SeriesA,
+ address(0)
+ )
+ );
+ CertificateDetails memory details = CertificateDetails({
+ signingOfficerName: "Officer",
+ signingOfficerTitle: "Title",
+ investmentAmountUSD: 1000,
+ issuerUSDValuationAtTimeOfInvestment: 10000,
+ unitsRepresented: units,
+ legalDetails: "",
+ extensionData: bytes("")
+ });
+ issuanceManager.createCertAndAssign(address(cert), seller, details);
+ }
+
+ function _deployScrip(address cert) internal {
+ issuanceManager.deployCyberScrip(
+ cert,
+ new ITransferRestrictionHook[](0),
+ new ICondition[](0),
+ new ICondition[](0),
+ 0, // scripToCertMinimum
+ 1, // ratio numerator
+ 1, // ratio denominator
+ new uint256[](0),
+ false, // whitelist disabled
+ true,
+ true,
+ true
+ );
+ }
+
+ function _dealMetadata(
+ address cert,
+ uint256 tokenId,
+ uint256 units,
+ string memory buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig
+ ) internal view returns (bytes memory) {
+ return _dealMetadataFor(buyer, cert, tokenId, units, buyerName, buyerHostingMode, adminMultisig);
+ }
+
+ function _dealMetadataFor(
+ address buyerAddr,
+ address cert,
+ uint256 tokenId,
+ uint256 units,
+ string memory buyerName,
+ HostingMode buyerHostingMode,
+ address adminMultisig
+ ) internal pure returns (bytes memory) {
+ return abi.encode(
+ cert,
+ tokenId,
+ units,
+ buyerAddr,
+ buyerName,
+ buyerHostingMode,
+ adminMultisig,
+ ExemptionPathway.SECTION_4A7,
+ SETTLEMENT_ID,
+ SELLER_SIG
+ );
+ }
+
+ /// @dev The buyer's new token carries one mirror endorsement (index 0) back-pointing to the seller and the
+ /// settlement agreement, reusing the seller's open-endorsement signature.
+ function _assertMirrorEndorsement(ICyberCertPrinter cert, uint256 buyerTokenId, string memory buyerName)
+ internal
+ view
+ {
+ // Concrete type: the ICyberCertPrinter interface declares a stale flat-tuple return; the contract
+ // returns the Endorsement struct.
+ Endorsement memory mirror = CyberCertPrinter(address(cert)).getEndorsementHistory(buyerTokenId, 0);
+ assertEq(mirror.endorser, seller, "mirror endorser is the seller");
+ assertEq(mirror.endorsee, buyer, "mirror endorsee is the buyer");
+ assertEq(mirror.endorseeName, buyerName, "mirror endorsee name");
+ assertEq(mirror.agreementId, SETTLEMENT_ID, "mirror bound to the settlement");
+ assertEq(mirror.signatureHash, SELLER_SIG, "mirror reuses the seller signature");
+ }
+
+ /// @dev secondaryTransfer materializes the seller's real endorsement on the seller token at finalization
+ /// (no open endorsement is written at acceptance). It sits at index 1 — index 0 is the endorsement the mint
+ /// (createCertAndAssign) records. Endorser is the seller of record (spec §3676-3680), endorsee the now-known
+ /// buyer.
+ function _assertSellerEndorsement(ICyberCertPrinter cert, uint256 sellerTokenId, string memory buyerName)
+ internal
+ view
+ {
+ Endorsement memory e = CyberCertPrinter(address(cert)).getEndorsementHistory(sellerTokenId, 1);
+ assertEq(e.endorser, seller, "seller-token endorser is the seller");
+ assertEq(e.endorsee, buyer, "seller-token endorsee is the buyer");
+ assertEq(e.endorseeName, buyerName, "seller-token endorsee name");
+ assertEq(e.agreementId, SETTLEMENT_ID, "seller endorsement bound to the settlement");
+ assertEq(e.signatureHash, SELLER_SIG, "seller endorsement carries the seller signature");
+ }
+}
diff --git a/test/IssuanceManagerCertificateCreatedEventTest.t.sol b/test/IssuanceManagerTest.t.sol
similarity index 72%
rename from test/IssuanceManagerCertificateCreatedEventTest.t.sol
rename to test/IssuanceManagerTest.t.sol
index 899e8415..c5520ac7 100644
--- a/test/IssuanceManagerCertificateCreatedEventTest.t.sol
+++ b/test/IssuanceManagerTest.t.sol
@@ -7,11 +7,12 @@ import "../src/CyberCertPrinter.sol";
import "../src/CyberScrip.sol";
import "../src/interfaces/ICyberCertPrinter.sol";
import "../src/interfaces/IUriBuilder.sol";
+import {RestrictiveLegend} from "../src/storage/CyberCertPrinterStorage.sol";
import "../src/libs/auth.sol";
import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IssuanceManagerFactory} from "../src/IssuanceManagerFactory.sol";
-contract MockCyberCorpForCertEvent {
+contract MockCyberCorpForIM {
function cyberCORPName() external pure returns (string memory) { return "TestCorp"; }
function cyberCORPType() external pure returns (string memory) { return "C-Corp"; }
function cyberCORPJurisdiction() external pure returns (string memory) { return "DE"; }
@@ -20,7 +21,7 @@ contract MockCyberCorpForCertEvent {
function roundManager() external pure returns (address) { return address(0); }
}
-contract MockUriBuilderForCertEvent is IUriBuilder {
+contract MockUriBuilderForIM is IUriBuilder {
function buildCertificateUri(
string memory,
string memory,
@@ -40,6 +41,25 @@ contract MockUriBuilderForCertEvent is IUriBuilder {
address
) external pure returns (string memory) { return "uri://mock"; }
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) { return "uri://mock"; }
+
function buildCertificateUriNotEncoded(
string memory,
string memory,
@@ -58,10 +78,29 @@ contract MockUriBuilderForCertEvent is IUriBuilder {
address,
address
) external pure returns (string memory) { return "uri://mock"; }
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) { return "uri://mock"; }
}
-contract IssuanceManagerCertificateCreatedEventTest is Test {
- bytes32 constant SALT = bytes32(keccak256("IssuanceManagerCertificateCreatedEventTest"));
+contract IssuanceManagerTest is Test {
+ bytes32 constant SALT = bytes32(keccak256("IssuanceManagerTest"));
IssuanceManager public issuanceManager;
BorgAuth public auth;
@@ -91,8 +130,8 @@ contract IssuanceManagerCertificateCreatedEventTest is Test {
issuanceManager = IssuanceManager(imFactory.deployIssuanceManager(SALT));
issuanceManager.initialize(
address(auth),
- address(new MockCyberCorpForCertEvent()),
- address(new MockUriBuilderForCertEvent()),
+ address(new MockCyberCorpForIM()),
+ address(new MockUriBuilderForIM()),
address(imFactory)
);
}
@@ -153,6 +192,24 @@ contract IssuanceManagerCertificateCreatedEventTest is Test {
);
}
+ function test_isPrinter_TrueForCreatedPrinter() public {
+ ICyberCertPrinter certPrinter = _deployPrinter("Cert", "CERT");
+ assertTrue(issuanceManager.isPrinter(address(certPrinter)), "created printer should be tracked");
+ }
+
+ function test_isPrinter_FalseForUnknownAddress() public {
+ _deployPrinter("Cert", "CERT");
+ assertFalse(issuanceManager.isPrinter(address(0xBEEF)), "foreign address is not a printer");
+ assertFalse(issuanceManager.isPrinter(address(0)), "zero address is not a printer");
+ }
+
+ function test_isPrinter_TracksMultiplePrinters() public {
+ ICyberCertPrinter a = _deployPrinter("CertA", "CERTA");
+ ICyberCertPrinter b = _deployPrinter("CertB", "CERTB");
+ assertTrue(issuanceManager.isPrinter(address(a)), "first printer tracked");
+ assertTrue(issuanceManager.isPrinter(address(b)), "second printer tracked");
+ }
+
function _deployPrinter(
string memory name,
string memory symbol
diff --git a/test/LegalOwnerBugFix.t.sol b/test/LegalOwnerBugFix.t.sol
index d9fb0058..38539709 100644
--- a/test/LegalOwnerBugFix.t.sol
+++ b/test/LegalOwnerBugFix.t.sol
@@ -14,6 +14,7 @@ import "../src/interfaces/ITransferRestrictionHook.sol";
import "../src/interfaces/ICondition.sol";
import "../src/interfaces/IUriBuilder.sol";
import "../src/libs/auth.sol";
+import {RestrictiveLegend} from "../src/storage/CyberCertPrinterStorage.sol";
contract LegalOwnerMockCyberCorp {
function cyberCORPName() external pure returns (string memory) {
@@ -63,6 +64,27 @@ contract LegalOwnerMockUriBuilder is IUriBuilder {
return "uri://mock";
}
+ function buildCertificateUri(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return "uri://mock";
+ }
+
function buildCertificateUriNotEncoded(
string memory,
string memory,
@@ -83,6 +105,27 @@ contract LegalOwnerMockUriBuilder is IUriBuilder {
) external pure returns (string memory) {
return "uri://mock";
}
+
+ function buildCertificateUriNotEncoded(
+ string memory,
+ string memory,
+ string memory,
+ string memory,
+ SecurityClass,
+ SecuritySeries,
+ string memory,
+ RestrictiveLegend[] memory,
+ CertificateDetails memory,
+ Endorsement[] memory,
+ OwnerDetails memory,
+ address,
+ bytes32,
+ uint256,
+ address,
+ address
+ ) external pure returns (string memory) {
+ return "uri://mock";
+ }
}
contract LegalOwnerBugPOCTest is Test {
diff --git a/test/LexScroWLiteTest.t.sol b/test/LexScrowStorageTest.t.sol
similarity index 88%
rename from test/LexScroWLiteTest.t.sol
rename to test/LexScrowStorageTest.t.sol
index 853506be..3e01fb58 100644
--- a/test/LexScroWLiteTest.t.sol
+++ b/test/LexScrowStorageTest.t.sol
@@ -47,8 +47,8 @@ import {ERC721} from "openzeppelin-contracts/token/ERC721/ERC721.sol";
import {ERC1155} from "openzeppelin-contracts/token/ERC1155/ERC1155.sol";
import {ERC721Enumerable} from "openzeppelin-contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import {BorgAuth} from "../src/libs/auth.sol";
-import {LexScroWLite} from "../src/libs/LexScroWLite.sol";
-import {LexScrowStorage, Token, TokenType, EscrowStatus} from "../src/storage/LexScrowStorage.sol";
+import {LexScrowStorage} from "../src/storage/LexScrowStorage.sol";
+import {LexScrowStorage, Escrow, Token, TokenType, EscrowStatus} from "../src/storage/LexScrowStorage.sol";
import {Endorsement} from "../src/storage/CyberCertPrinterStorage.sol";
import {ICondition} from "../src/interfaces/ICondition.sol";
@@ -106,38 +106,37 @@ contract ConditionMock is ICondition {
}
}
-contract LexScroWLiteMock is LexScroWLite {
- /// @custom:oz-upgrades-unsafe-allow constructor
- constructor() {
- _disableInitializers();
- }
-
- function initialize(address _corp, address _dealRegistry) public initializer {
- __LexScroWLite_init(_corp, _dealRegistry);
+// Harness exercising LexScrowStorage, now a linked library (was an abstract base contract).
+// Mirrors a real manager: thin wrappers over LexScrowStorage.* + the fee/receiver hooks the library
+// expects on the calling contract (ILexScrowStorage(address(this)) callback; ERC721/1155 receipt).
+contract LexScrowStorageMock {
+ function initialize(address _corp, address _dealRegistry) public {
+ LexScrowStorage.setCorp(_corp);
+ LexScrowStorage.setDealRegistry(_dealRegistry);
}
function createEscrow_(bytes32 agreementId, address counterParty, Token[] memory corpAssets, Token[] memory buyerAssets, uint256 expiry) public {
- createEscrow(agreementId, counterParty, corpAssets, buyerAssets, expiry);
+ LexScrowStorage.createEscrow(agreementId, counterParty, corpAssets, buyerAssets, expiry);
}
function updateEscrow_(bytes32 agreementId, address counterParty, string memory buyerName) public {
- updateEscrow(agreementId, counterParty, buyerName);
+ LexScrowStorage.updateEscrow(agreementId, counterParty, buyerName);
}
function handleCounterPartyPayment_(bytes32 agreementId) public {
- handleCounterPartyPayment(agreementId);
+ LexScrowStorage.handleCounterPartyPayment(agreementId);
}
function finalizeEscrow_(bytes32 agreementId) public {
- finalizeEscrow(agreementId);
+ LexScrowStorage.finalizeEscrow(agreementId);
}
function voidAndRefund_(bytes32 agreementId) public {
- voidAndRefund(agreementId);
+ LexScrowStorage.voidAndRefund(agreementId);
}
function voidEscrow_(bytes32 agreementId) public {
- voidEscrow(agreementId);
+ LexScrowStorage.voidEscrow(agreementId);
}
function addConditions(bytes32 agreementId, address[] calldata conditions) public {
@@ -146,13 +145,29 @@ contract LexScroWLiteMock is LexScroWLite {
}
}
- function computeFee(uint256 size) public override view returns (uint256) {
+ function getEscrowDetails(bytes32 agreementId) public view returns (Escrow memory) {
+ return LexScrowStorage.getEscrow(agreementId);
+ }
+
+ function conditionCheck(bytes32 agreementId) public view returns (bool) {
+ return LexScrowStorage.conditionCheck(agreementId);
+ }
+
+ function computeFee(uint256) public pure returns (uint256) {
return 0; // Not in use
}
- function getPlatformPayable() public override view returns (address) {
+ function getPlatformPayable() public pure returns (address) {
return address(0); // Not in use
}
+
+ function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC721Received.selector;
+ }
+
+ function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) {
+ return this.onERC1155Received.selector;
+ }
}
contract CyberAgreementRegistryMock {
@@ -171,9 +186,9 @@ contract CyberCorpMock {
}
}
-contract LexScroWLiteTest is Test {
+contract LexScrowStorageTest is Test {
- bytes32 public salt = keccak256("LexScroWLiteTest");
+ bytes32 public salt = keccak256("LexScrowStorageTest");
uint256 public ownerPrivateKey = uint256(salt) + 0;
address public owner = vm.addr(ownerPrivateKey);
@@ -199,19 +214,19 @@ contract LexScroWLiteTest is Test {
CyberAgreementRegistryMock public registry;
CyberCorpMock public corp;
- LexScroWLiteMock public lexScrow;
+ LexScrowStorageMock public lexScrow;
function setUp() public {
registry = new CyberAgreementRegistryMock{salt: salt}();
corp = new CyberCorpMock{salt: salt}(companyPayable);
- lexScrow = LexScroWLiteMock(
+ lexScrow = LexScrowStorageMock(
address(
new ERC1967Proxy{salt: salt}(
- address(new LexScroWLiteMock{salt: salt}()),
+ address(new LexScrowStorageMock{salt: salt}()),
abi.encodeWithSelector(
- LexScroWLiteMock.initialize.selector,
+ LexScrowStorageMock.initialize.selector,
address(corp),
address(registry)
)
@@ -241,7 +256,7 @@ contract LexScroWLiteTest is Test {
// Escrow configs
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -294,7 +309,7 @@ contract LexScroWLiteTest is Test {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -330,7 +345,7 @@ contract LexScroWLiteTest is Test {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -370,7 +385,7 @@ contract LexScroWLiteTest is Test {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -398,7 +413,7 @@ contract LexScroWLiteTest is Test {
assertEq(buyerTokenErc721.ownerOf(buyerTokenErc721Id), alice, "Alice should have ERC712 token refund");
assertEq(buyerTokenErc1155.balanceOf(alice, buyerTokenErc1155Id), 100 ether, "Alice should have ERC1155 token refund");
- // Note LexScroWLite by default does not implement corp asset refunds, so they will be stuck in escrow
+ // Note LexScrowStorage by default does not implement corp asset refunds, so they will be stuck in escrow
assertEq(corpTokenErc20.balanceOf(address(lexScrow)), 10 ether, "Corp's ERC20 token should still be in escrow");
assertEq(corpTokenErc721.ownerOf(corpTokenErc721Id), address(lexScrow), "Corp's ERC712 token should still be in escrow");
@@ -413,7 +428,7 @@ contract LexScroWLiteTest is Test {
// Prepare escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -421,7 +436,7 @@ contract LexScroWLiteTest is Test {
// Escrow is unpaid
// Should fail since the escrow is unpaid
- vm.expectRevert(LexScroWLite.EscrowNotPaid.selector);
+ vm.expectRevert(LexScrowStorage.EscrowNotPaid.selector);
lexScrow.voidAndRefund_(agreementId);
}
@@ -430,7 +445,7 @@ contract LexScroWLiteTest is Test {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -440,14 +455,14 @@ contract LexScroWLiteTest is Test {
// Agreement is not voided yet
// Should fail since agreement is not voided first
- vm.expectRevert(LexScroWLite.DealNotVoided.selector);
+ vm.expectRevert(LexScrowStorage.DealNotVoided.selector);
lexScrow.voidAndRefund_(agreementId);
}
function test_UpdateEscrowNonERC721() public {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = new Token[](2);
corpAssets[0] = Token({
tokenType: TokenType.ERC20,
@@ -478,7 +493,7 @@ contract LexScroWLiteTest is Test {
// updateEscrow should update the counter-party of the escrow, and if the token is ERC721,
// it assumes the token implements ICyberCertPrinter and will add endorsement to it.
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = new Token[](1);
corpAssets[0] = Token({
tokenType: TokenType.ERC721,
@@ -500,7 +515,7 @@ contract LexScroWLiteTest is Test {
// updateEscrow assumes an ERC721 token implements ICyberCertPrinter and
// will fail if it does not implement `addEndorsement()`
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -517,7 +532,7 @@ contract LexScroWLiteTest is Test {
assertEq(uint8(lexScrow.getEscrowDetails(agreementId).status), uint8(EscrowStatus.PENDING));
vm.expectEmit(true, true, true, true);
- emit LexScroWLite.DealVoidedAt(agreementId, address(registry), block.timestamp);
+ emit LexScrowStorage.DealVoidedAt(agreementId, address(registry), block.timestamp);
lexScrow.voidEscrow_(agreementId);
assertEq(uint8(lexScrow.getEscrowDetails(agreementId).status), uint8(EscrowStatus.VOIDED));
}
@@ -527,7 +542,7 @@ contract LexScroWLiteTest is Test {
// Prepare Escrow
- bytes32 agreementId = keccak256("LexScroWLiteTest.Agreement");
+ bytes32 agreementId = keccak256("LexScrowStorageTest.Agreement");
Token[] memory corpAssets = _getCorpAssets();
Token[] memory buyerAssets = _getBuyerAssets();
@@ -537,7 +552,7 @@ contract LexScroWLiteTest is Test {
assertEq(uint8(lexScrow.getEscrowDetails(agreementId).status), uint8(EscrowStatus.PAID));
vm.expectEmit(true, true, true, true);
- emit LexScroWLite.DealVoidedAt(agreementId, address(registry), block.timestamp);
+ emit LexScrowStorage.DealVoidedAt(agreementId, address(registry), block.timestamp);
lexScrow.voidEscrow_(agreementId);
assertEq(uint8(lexScrow.getEscrowDetails(agreementId).status), uint8(EscrowStatus.VOIDED));
}
diff --git a/test/PumpCorpFactory.t.sol b/test/PumpCorpFactory.t.sol
index f8e38a54..46e8e42a 100644
--- a/test/PumpCorpFactory.t.sol
+++ b/test/PumpCorpFactory.t.sol
@@ -7,6 +7,7 @@ import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.so
import {DeployPumpCorpFactoryScript} from "../script/deploy-pump-factory.s.sol";
import {PumpCorpFactory, PumpCorpFactoryLib} from "../src/PumpCorpFactory.sol";
import {RoundManager} from "../src/RoundManager.sol";
+import {ILexScrowStorage} from "../src/interfaces/ILexScrowStorage.sol";
import {RoundManagerFactory} from "../src/RoundManagerFactory.sol";
import {FeeOverride} from "../src/interfaces/IRoundManagerFactory.sol";
import {CyberCorpSingleFactory} from "../src/CyberCorpSingleFactory.sol";
@@ -598,7 +599,7 @@ contract PumpCorpFactoryForkTest is Test {
lexchexDetails: _emptyLex()
});
- vm.expectRevert(RoundManager.AgreementConditionsNotMet.selector);
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector);
RoundManager(rm).submitEOI(
roundId, eoi,
globalValues, investorPv,
@@ -1874,7 +1875,7 @@ contract PumpCorpFactoryForkTest is Test {
});
bytes memory eoiSig = _eoiSig(1, globalValues, investorPv);
- vm.expectRevert(RoundManager.AgreementConditionsNotMet.selector);
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector);
RoundManager(rm).submitEOI(
roundId, eoi,
globalValues, investorPv,
diff --git a/test/RoundManagerForkTest.t.sol b/test/RoundManagerForkTest.t.sol
index abc67107..bd8e50ab 100644
--- a/test/RoundManagerForkTest.t.sol
+++ b/test/RoundManagerForkTest.t.sol
@@ -3,6 +3,7 @@ pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RoundManager.sol";
+import {ILexScrowStorage} from "../src/interfaces/ILexScrowStorage.sol";
import "../src/IssuanceManager.sol";
import "../src/CyberCertPrinter.sol";
import "../src/storage/RoundManagerStorage.sol";
@@ -152,7 +153,7 @@ contract RoundManagerFCFSForkTest is Test {
privKey
);
- vm.expectRevert(RoundManager.AgreementConditionsNotMet.selector);
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector);
rm.submitEOI(
roundId,
eoi,
@@ -529,7 +530,7 @@ contract RoundManagerFCFSForkTest is Test {
//make sure lexchex is not valid
assert(!ILexChex(0xc8db0c3f47656aee725b0AD1835F9A3FbD0a0b62).hasValidLexCheX(investor));
- vm.expectRevert(RoundManager.AgreementConditionsNotMet.selector);
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector);
rm.submitEOI(
roundId,
eoi,
@@ -681,7 +682,7 @@ contract RoundManagerFCFSForkTest is Test {
);
// SUBMIT EOI - Should succeed but NOT mint LexChex because token is not whitelisted
- vm.expectRevert(RoundManager.AgreementConditionsNotMet.selector);
+ vm.expectRevert(ILexScrowStorage.AgreementConditionsNotMet.selector);
rmPub.submitEOI(
pubRoundId,
eoi,
diff --git a/test/RoundManagerTest.t.sol b/test/RoundManagerTest.t.sol
index 6e6cd490..569346b8 100644
--- a/test/RoundManagerTest.t.sol
+++ b/test/RoundManagerTest.t.sol
@@ -3,6 +3,7 @@ pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RoundManager.sol";
+import {ILexScrowStorage} from "../src/interfaces/ILexScrowStorage.sol";
import "../src/IssuanceManager.sol";
import "../src/CyberCertPrinter.sol";
import "../src/storage/RoundManagerStorage.sol";
@@ -2095,7 +2096,7 @@ contract RoundManagerTest is Test {
vm.prank(corpOwner);
vm.expectRevert(
abi.encodeWithSelector(
- RoundManager.AgreementConditionsNotMet.selector
+ ILexScrowStorage.AgreementConditionsNotMet.selector
)
);
RoundManager(roundManager).allocate(agreementId, 10_000 * 10 ** 6);
diff --git a/test/ScripPOC.t.sol b/test/ScripPOC.t.sol
index 5a34e56e..48aa04e1 100644
--- a/test/ScripPOC.t.sol
+++ b/test/ScripPOC.t.sol
@@ -72,6 +72,15 @@ contract POCMockCertPrinter {
return _ownedTokens[owner_][index];
}
+ // In this mock custody owner == legal owner, so the per-legal-owner enumeration is the same backing data.
+ function balanceOfLegalOwner(address owner_) external view returns (uint256) {
+ return _balances[owner_];
+ }
+
+ function tokenOfLegalOwnerByIndex(address owner_, uint256 index) external view returns (uint256) {
+ return _ownedTokens[owner_][index];
+ }
+
function getCertificateDetails(uint256 tokenId) external view returns (CertificateDetails memory) {
return _details[tokenId];
}
@@ -94,6 +103,7 @@ contract POCMockCertPrinter {
function voidCert(uint256 tokenId) external { _voided[tokenId] = true; }
function isVoided(uint256 tokenId) external view returns (bool) { return _voided[tokenId]; }
+ function unitsReserved(uint256) external pure returns (uint256) { return 0; }
function legalOwnerOf(uint256 tokenId) external view returns (address) { return _owners[tokenId]; }
/// @dev Mock safeTransferFrom -- no IERC721Receiver check, no endorsement check.