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( + '', + '', + generateMetaLeXLogo(), + generateSVGBody(title, cred), + "" + ) + ); + } + + 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.