From 36b5725bbdb982b4631af7e7e88e187e7f949321 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 11 May 2026 16:42:25 -0700 Subject: [PATCH] feat: add GlobalKillCondition for deal/cert/scrip --- src/libs/conditions/GlobalKillCondition.sol | 62 ++++ test/GlobalKillCondition.t.sol | 299 ++++++++++++++++++++ test/GlobalKillConditionFork.t.sol | 283 ++++++++++++++++++ 3 files changed, 644 insertions(+) create mode 100644 src/libs/conditions/GlobalKillCondition.sol create mode 100644 test/GlobalKillCondition.t.sol create mode 100644 test/GlobalKillConditionFork.t.sol diff --git a/src/libs/conditions/GlobalKillCondition.sol b/src/libs/conditions/GlobalKillCondition.sol new file mode 100644 index 0000000..028f86e --- /dev/null +++ b/src/libs/conditions/GlobalKillCondition.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.28; + +import "openzeppelin-contracts/interfaces/IERC165.sol"; +import "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol"; +import "./baseCondition.sol"; +import "../auth.sol"; +import "../../interfaces/ITransferRestrictionHook.sol"; + +/// @title GlobalKillCondition +/// @notice Platform-wide kill switch gating deal finalization and cert/scrip transfers. +/// @dev Any BorgAuth owner can raise their kill vote; the kill is active while any vote is raised. +/// Implements ICondition (for DealManager) and ITransferRestrictionHook (for CyberScrip / CyberCertPrinter). +contract GlobalKillCondition is BaseCondition, BorgAuthACL, ITransferRestrictionHook { + event KillRaised(address indexed by); + event KillLowered(address indexed by); + + mapping(address => bool) public killVotes; + uint256 public killCount; + + constructor(address _auth) { + initialize(_auth); + } + + function initialize(address _auth) public initializer { + __BorgAuthACL_init(_auth); + } + + function raiseKill() external onlyOwner { + if (!killVotes[msg.sender]) { + killVotes[msg.sender] = true; + killCount++; + emit KillRaised(msg.sender); + } + } + + function lowerKill() external onlyOwner { + if (killVotes[msg.sender]) { + killVotes[msg.sender] = false; + killCount--; + emit KillLowered(msg.sender); + } + } + + function isKilled() public view returns (bool) { + return killCount > 0; + } + + function checkCondition(address, bytes4, bytes memory) public view override returns (bool) { + return !isKilled(); + } + + function checkTransferRestriction(address, address, uint256, bytes memory) + external + view + override + returns (bool allowed, string memory reason) + { + if (isKilled()) return (false, "Global kill switch active"); + return (true, ""); + } +} diff --git a/test/GlobalKillCondition.t.sol b/test/GlobalKillCondition.t.sol new file mode 100644 index 0000000..97d7653 --- /dev/null +++ b/test/GlobalKillCondition.t.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {GlobalKillCondition} from "../src/libs/conditions/GlobalKillCondition.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {ICondition} from "../src/interfaces/ICondition.sol"; +import {ITransferRestrictionHook} from "../src/interfaces/ITransferRestrictionHook.sol"; +import {IERC165} from "openzeppelin-contracts/interfaces/IERC165.sol"; + +contract GlobalKillConditionTest is Test { + BorgAuth internal auth; + GlobalKillCondition internal kill; + + address internal admin; + address internal coAdmin; + address internal stranger; + + function setUp() public { + admin = makeAddr("admin"); + coAdmin = makeAddr("coAdmin"); + stranger = makeAddr("stranger"); + + auth = new BorgAuth(admin); + + vm.startPrank(admin); + auth.updateRole(coAdmin, auth.OWNER_ROLE()); + vm.stopPrank(); + + kill = new GlobalKillCondition(address(auth)); + } + + // ─── Kill vote logic ────────────────────────────────────────────────────── + + function test_defaultNotKilled() public view { + assertFalse(kill.isKilled()); + assertEq(kill.killCount(), 0); + } + + function test_ownerRaisesKill() public { + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit GlobalKillCondition.KillRaised(admin); + kill.raiseKill(); + + assertTrue(kill.isKilled()); + assertEq(kill.killCount(), 1); + assertTrue(kill.killVotes(admin)); + } + + function test_coOwnerRaisesKill() public { + vm.prank(coAdmin); + kill.raiseKill(); + + assertTrue(kill.isKilled()); + assertEq(kill.killCount(), 1); + assertTrue(kill.killVotes(coAdmin)); + } + + function test_bothRaise_countIsTwo() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(coAdmin); + kill.raiseKill(); + + assertEq(kill.killCount(), 2); + assertTrue(kill.isKilled()); + } + + function test_ownerClears_coOwnerStillRaised() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(coAdmin); + kill.raiseKill(); + + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit GlobalKillCondition.KillLowered(admin); + kill.lowerKill(); + + assertTrue(kill.isKilled()); + assertEq(kill.killCount(), 1); + assertFalse(kill.killVotes(admin)); + assertTrue(kill.killVotes(coAdmin)); + } + + function test_bothClear_notKilled() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(coAdmin); + kill.raiseKill(); + + vm.prank(admin); + kill.lowerKill(); + vm.prank(coAdmin); + kill.lowerKill(); + + assertFalse(kill.isKilled()); + assertEq(kill.killCount(), 0); + } + + function test_raiseIdempotent() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(admin); + kill.raiseKill(); + + assertEq(kill.killCount(), 1); + } + + function test_lowerIdempotent() public { + vm.prank(admin); + kill.lowerKill(); + + assertEq(kill.killCount(), 0); + assertFalse(kill.isKilled()); + } + + function test_lowerOnlyAffectsOwnVote() public { + vm.prank(coAdmin); + kill.raiseKill(); + + vm.prank(admin); + kill.lowerKill(); // admin had no vote raised + + assertEq(kill.killCount(), 1); + assertTrue(kill.isKilled()); + } + + // ─── Access control ─────────────────────────────────────────────────────── + + function test_strangerCannotRaise() public { + bytes memory expectedErr = abi.encodeWithSelector( + BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), stranger + ); + vm.prank(stranger); + vm.expectRevert(expectedErr); + kill.raiseKill(); + } + + function test_strangerCannotLower() public { + bytes memory expectedErr = abi.encodeWithSelector( + BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), stranger + ); + vm.prank(stranger); + vm.expectRevert(expectedErr); + kill.lowerKill(); + } + + // ─── ICondition ─────────────────────────────────────────────────────────── + + function test_checkCondition_notKilled() public view { + assertTrue(kill.checkCondition(address(0), bytes4(0), "")); + } + + function test_checkCondition_killed() public { + vm.prank(admin); + kill.raiseKill(); + + assertFalse(kill.checkCondition(address(0), bytes4(0), "")); + } + + function test_checkCondition_ignoredParams() public { + // Result depends only on kill state regardless of params + assertTrue(kill.checkCondition(address(this), bytes4(keccak256("finalizeDeal(bytes32)")), abi.encode(bytes32(0)))); + + vm.prank(admin); + kill.raiseKill(); + + assertFalse(kill.checkCondition(address(this), bytes4(keccak256("finalizeDeal(bytes32)")), abi.encode(bytes32(0)))); + } + + // ─── ITransferRestrictionHook ───────────────────────────────────────────── + + function test_transferAllowed_notKilled() public view { + (bool allowed, string memory reason) = kill.checkTransferRestriction(address(0), address(1), 100, ""); + assertTrue(allowed); + assertEq(reason, ""); + } + + function test_transferBlocked_oneVote() public { + vm.prank(admin); + kill.raiseKill(); + + (bool allowed, string memory reason) = kill.checkTransferRestriction(address(0), address(1), 100, ""); + assertFalse(allowed); + assertEq(reason, "Global kill switch active"); + } + + function test_transferAllowed_afterAllCleared() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(coAdmin); + kill.raiseKill(); + + vm.prank(admin); + kill.lowerKill(); + vm.prank(coAdmin); + kill.lowerKill(); + + (bool allowed,) = kill.checkTransferRestriction(address(0), address(1), 100, ""); + assertTrue(allowed); + } + + // ─── ERC165 ─────────────────────────────────────────────────────────────── + + function test_supportsICondition() public view { + assertTrue(kill.supportsInterface(type(ICondition).interfaceId)); + } + + function test_supportsIERC165() public view { + assertTrue(kill.supportsInterface(type(IERC165).interfaceId)); + } + + // ─── Shared instance (one kill wired to deal + cert + scrip) ───────────── + + function test_shared_noKill_allPass() public view { + // Simulates all three consumers reading from the same GlobalKillCondition + assertTrue(kill.checkCondition(address(0), bytes4(0), "")); + (bool certAllowed,) = kill.checkTransferRestriction(address(0), address(1), 1, ""); // cert (ERC721 tokenId=1) + (bool scripAllowed,) = kill.checkTransferRestriction(address(0), address(1), 100, ""); // scrip (ERC20 amount=100) + assertTrue(certAllowed); + assertTrue(scripAllowed); + } + + function test_shared_kill_allBlock() public { + vm.prank(admin); + kill.raiseKill(); + + assertFalse(kill.checkCondition(address(0), bytes4(0), "")); + (bool certAllowed,) = kill.checkTransferRestriction(address(0), address(1), 1, ""); + (bool scripAllowed,) = kill.checkTransferRestriction(address(0), address(1), 100, ""); + assertFalse(certAllowed); + assertFalse(scripAllowed); + } + + function test_shared_lower_allResume() public { + vm.prank(admin); + kill.raiseKill(); + vm.prank(admin); + kill.lowerKill(); + + assertTrue(kill.checkCondition(address(0), bytes4(0), "")); + (bool certAllowed,) = kill.checkTransferRestriction(address(0), address(1), 1, ""); + (bool scripAllowed,) = kill.checkTransferRestriction(address(0), address(1), 100, ""); + assertTrue(certAllowed); + assertTrue(scripAllowed); + } + + // ─── Dedicated instances (one per target) ──────────────────────────────── + + function test_dedicated_independent_state() public { + GlobalKillCondition dealKill = new GlobalKillCondition(address(auth)); + GlobalKillCondition certKill = new GlobalKillCondition(address(auth)); + GlobalKillCondition scripKill = new GlobalKillCondition(address(auth)); + + vm.prank(admin); + dealKill.raiseKill(); + + assertTrue(dealKill.isKilled()); + assertFalse(certKill.isKilled()); + assertFalse(scripKill.isKilled()); + } + + function test_dedicated_raiseCert_dealUnaffected() public { + GlobalKillCondition dealKill = new GlobalKillCondition(address(auth)); + GlobalKillCondition certKill = new GlobalKillCondition(address(auth)); + + vm.prank(admin); + certKill.raiseKill(); + + assertFalse(dealKill.isKilled()); + assertTrue(certKill.isKilled()); + } + + function test_dedicated_allCanBeRaisedIndependently() public { + GlobalKillCondition dealKill = new GlobalKillCondition(address(auth)); + GlobalKillCondition certKill = new GlobalKillCondition(address(auth)); + GlobalKillCondition scripKill = new GlobalKillCondition(address(auth)); + + vm.prank(admin); + dealKill.raiseKill(); + vm.prank(admin); + certKill.raiseKill(); + vm.prank(admin); + scripKill.raiseKill(); + + assertTrue(dealKill.isKilled()); + assertTrue(certKill.isKilled()); + assertTrue(scripKill.isKilled()); + + vm.prank(admin); + dealKill.lowerKill(); + + assertFalse(dealKill.isKilled()); + assertTrue(certKill.isKilled()); + assertTrue(scripKill.isKilled()); + } +} diff --git a/test/GlobalKillConditionFork.t.sol b/test/GlobalKillConditionFork.t.sol new file mode 100644 index 0000000..ab92fbf --- /dev/null +++ b/test/GlobalKillConditionFork.t.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {CyberAgreementUtils} from "./libs/CyberAgreementUtils.sol"; +import {DeploymentConstants} from "../script/libs/DeploymentConstants.sol"; +import {CyberAgreementRegistry} from "../src/CyberAgreementRegistry.sol"; +import {CyberCorpFactory} from "../src/CyberCorpFactory.sol"; +import {DealManager} from "../src/DealManager.sol"; +import {IDealManager} from "../src/interfaces/IDealManager.sol"; +import {IIssuanceManager} from "../src/interfaces/IIssuanceManager.sol"; +import {ICondition} from "../src/interfaces/ICondition.sol"; +import {ITransferRestrictionHook} from "../src/interfaces/ITransferRestrictionHook.sol"; +import {CyberCertPrinter} from "../src/CyberCertPrinter.sol"; +import {CyberScrip} from "../src/CyberScrip.sol"; +import {GlobalKillCondition} from "../src/libs/conditions/GlobalKillCondition.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {IERC20} from "openzeppelin-contracts/interfaces/IERC20.sol"; +import {CertificateDetails, Endorsement} from "../src/storage/CyberCertPrinterStorage.sol"; +import {CompanyOfficer, SecurityClass, SecuritySeries} from "../src/CyberCorpConstants.sol"; + + +/// @notice Integration test: GlobalKillCondition wired into DealManager (ICondition), +/// CyberScrip (transfer hook), and CyberCertPrinter (transfer hook). +/// One combined test raises and lowers the kill switch and verifies all three +/// systems block then resume. +/// +/// Run with: +/// forge test --via-ir --optimize --optimizer-runs 15 --use solc:0.8.28 \ +/// --fork-url --mc GlobalKillConditionForkTest +contract GlobalKillConditionForkTest is Test { + uint256 internal constant PAYMENT = 1_000_000e6; + uint256 internal constant SCRIP_AMOUNT = 1000 ether; + + bytes32 internal constant TEMPLATE_ID = bytes32(uint256(88_888_888)); + uint256 internal constant AGREEMENT_SALT = 77_777_777; + string internal constant LEGAL_URI = "ipfs://kill-condition-integration-test"; + + CyberAgreementRegistry internal registry; + CyberCorpFactory internal factory; + BorgAuth internal killAuth; + GlobalKillCondition internal kill; + + address internal founder; + uint256 internal founderPk; + address internal investor; + uint256 internal investorPk; + address internal recipient; + + address internal issuanceManagerAddr; + address internal dealManagerAddr; + address internal certPrinterAddr; + bytes32 internal agreementId; + uint256 internal dealCertId; + uint256 internal transferCertId; + CyberScrip internal scrip; + + function setUp() public { + DeploymentConstants.CoreDeployment memory dep = DeploymentConstants.coreV2(block.chainid); + registry = CyberAgreementRegistry(dep.cyberAgreementRegistry); + factory = CyberCorpFactory(dep.cyberCorpFactory); + + (founder, founderPk) = makeAddrAndKey("founder"); + (investor, investorPk) = makeAddrAndKey("investor"); + recipient = makeAddr("recipient"); + + // investor address may have code on the forked chain; clear it so safe ERC721 mints succeed + vm.etch(investor, ""); + + killAuth = new BorgAuth(founder); + kill = new GlobalKillCondition(address(killAuth)); + + _createTemplate(dep.metalexSafe); + + string[] memory globalFields = new string[](1); + globalFields[0] = "Global Field 1"; + string[] memory partyFields = new string[](1); + partyFields[0] = "Party Field 1"; + string[] memory globalValues = new string[](1); + globalValues[0] = "Global Value 1"; + string[] memory founderValues = new string[](1); + founderValues[0] = "Founder Party Value"; + string[] memory investorValues = new string[](1); + investorValues[0] = "Investor Party Value"; + + address[] memory parties = new address[](2); + parties[0] = founder; + parties[1] = investor; + + bytes32 contractId = keccak256(abi.encode(TEMPLATE_ID, AGREEMENT_SALT, globalValues, parties)); + + bytes memory founderSig = CyberAgreementUtils.signAgreementTypedData( + vm, registry.DOMAIN_SEPARATOR(), registry.SIGNATUREDATA_TYPEHASH(), + contractId, LEGAL_URI, globalFields, partyFields, globalValues, founderValues, founderPk + ); + + string[] memory defaultLegend = new string[](1); + defaultLegend[0] = "Legend"; + CyberCorpFactory.CyberCertData[] memory certData = new CyberCorpFactory.CyberCertData[](1); + certData[0] = CyberCorpFactory.CyberCertData({ + name: "SAFE", + symbol: "SAFE", + uri: LEGAL_URI, + securityClass: SecurityClass.SAFE, + securitySeries: SecuritySeries.SeriesSeed, + extension: address(0), + defaultLegend: defaultLegend + }); + + CertificateDetails[] memory details = new CertificateDetails[](1); + details[0] = CertificateDetails({ + signingOfficerName: "Founder", + signingOfficerTitle: "CEO", + investmentAmountUSD: PAYMENT / 1e6, + issuerUSDValuationAtTimeOfInvestment: 10_000_000, + unitsRepresented: 0, + legalDetails: "SAFE Agreement", + extensionData: "" + }); + + string[][] memory partyValues = new string[][](2); + partyValues[0] = founderValues; + partyValues[1] = investorValues; + + address[] memory conditions = new address[](1); + conditions[0] = address(kill); + + address[] memory certPrinters; + bytes32 id; + uint256[] memory certIds; + + vm.prank(founder); + ( + , + , + issuanceManagerAddr, + dealManagerAddr, + , + certPrinters, + id, + certIds + ) = factory.deployCyberCorpAndCreateOffer( + AGREEMENT_SALT, + "Kill Test Corp", + "Delaware C-Corp", + "DE", + "kill@test.example", + "Arbitration", + founder, + CompanyOfficer({eoa: founder, name: "Founder", contact: "f@test.example", title: "CEO"}), + certData, + TEMPLATE_ID, + globalValues, + parties, + PAYMENT, + partyValues, + founderSig, + details, + conditions, + bytes32(0), + block.timestamp + 7 days + ); + + certPrinterAddr = certPrinters[0]; + agreementId = id; + dealCertId = certIds[0]; + + bytes memory investorSig = CyberAgreementUtils.signAgreementTypedData( + vm, registry.DOMAIN_SEPARATOR(), registry.SIGNATUREDATA_TYPEHASH(), + contractId, LEGAL_URI, globalFields, partyFields, globalValues, investorValues, investorPk + ); + + address stable = factory.stable(); + deal(stable, investor, PAYMENT); + + vm.startPrank(investor); + IERC20(stable).approve(dealManagerAddr, PAYMENT); + IDealManager(dealManagerAddr).signDealAndPay(investor, agreementId, investorSig, investorValues, false, "Investor", ""); + vm.stopPrank(); + + IIssuanceManager im = IIssuanceManager(issuanceManagerAddr); + + // Deploy scrip with kill as transfer hook + ITransferRestrictionHook[] memory hooks = new ITransferRestrictionHook[](1); + hooks[0] = ITransferRestrictionHook(address(kill)); + ICondition[] memory emptyConditions = new ICondition[](0); + uint256[] memory emptyIds = new uint256[](0); + + vm.prank(founder); + scrip = CyberScrip(im.deployCyberScrip( + certPrinterAddr, hooks, emptyConditions, emptyConditions, + 0, 1, 1, emptyIds, false, true, true, true + )); + + CertificateDetails memory certDetail = CertificateDetails({ + signingOfficerName: "Founder", + signingOfficerTitle: "CEO", + investmentAmountUSD: 0, + issuerUSDValuationAtTimeOfInvestment: 10_000_000, + unitsRepresented: SCRIP_AMOUNT, + legalDetails: "Cert", + extensionData: "" + }); + + // Seed investor's scrip balance directly (older deployed scrip has different mint access) + deal(address(scrip), investor, SCRIP_AMOUNT); + + // Mint cert for the cert-transfer test + vm.prank(founder); + transferCertId = im.createCertAndAssign(certPrinterAddr, investor, certDetail); + + // Endorsement for the transfer cert → recipient (checked after kill is lowered) + vm.prank(investor); + CyberCertPrinter(certPrinterAddr).addEndorsement( + transferCertId, + Endorsement({ + endorser: investor, + timestamp: block.timestamp, + signatureHash: "", + registry: address(0), + agreementId: bytes32(0), + endorsee: recipient, + endorseeName: "Recipient" + }) + ); + + // Enable cert transfers (otherwise TokenNotTransferable fires before the kill hook) + vm.prank(founder); + im.setGlobalTransferable(certPrinterAddr, true); + + // Wire kill as the global restriction hook + vm.prank(founder); + im.setGlobalRestrictionHook(certPrinterAddr, address(kill)); + } + + function test_kill_blocksAllThreeSystems() public { + vm.prank(founder); + kill.raiseKill(); + + // Deal finalization blocked by kill condition + vm.expectRevert(DealManager.AgreementConditionsNotMet.selector); + vm.prank(investor); + IDealManager(dealManagerAddr).finalizeDeal(agreementId); + + // Scrip transfer blocked by kill hook + vm.expectRevert(abi.encodeWithSignature("RestrictedTransfer(string)", "Global kill switch active")); + vm.prank(investor); + scrip.transfer(recipient, 1e6); + + // Cert transfer blocked by kill hook + vm.expectRevert(abi.encodeWithSignature("TransferRestricted(string)", "Global kill switch active")); + vm.prank(investor); + CyberCertPrinter(certPrinterAddr).transferFrom(investor, recipient, transferCertId); + + vm.prank(founder); + kill.lowerKill(); + + // Deal finalization succeeds after kill lowered + vm.prank(investor); + IDealManager(dealManagerAddr).finalizeDeal(agreementId); + assertEq(CyberCertPrinter(certPrinterAddr).ownerOf(dealCertId), investor); + + // Scrip transfer succeeds after kill lowered + vm.prank(investor); + scrip.transfer(recipient, 1e6); + assertGt(scrip.balanceOf(recipient), 0); + + // Cert transfer succeeds after kill lowered + vm.prank(investor); + CyberCertPrinter(certPrinterAddr).transferFrom(investor, recipient, transferCertId); + assertEq(CyberCertPrinter(certPrinterAddr).ownerOf(transferCertId), recipient); + } + + function _createTemplate(address metalexSafe) internal { + string[] memory globalFields = new string[](1); + globalFields[0] = "Global Field 1"; + string[] memory partyFields = new string[](1); + partyFields[0] = "Party Field 1"; + + vm.prank(metalexSafe); + registry.createTemplate(TEMPLATE_ID, "Kill Condition Test", LEGAL_URI, globalFields, partyFields); + } +}