diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 00000000..13ba12b2 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,11 @@ +{ + "dependencies/forge-std": { + "rev": "8ba9031ffcbe25aa0d1224d3ca263a995026e477" + }, + "dependencies/openzeppelin-contracts": { + "rev": "fda6b85f2c65d146b86d513a604554d15abd6679" + }, + "dependencies/openzeppelin-contracts-upgradeable": { + "rev": "36ec7079af1a68bd866f6b9f4cf2f4dddee1e7bc" + } +} \ No newline at end of file diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol index cb937684..33c9ddf1 100644 --- a/src/CyberCertPrinter.sol +++ b/src/CyberCertPrinter.sol @@ -90,6 +90,11 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { event RestrictionHookSet(uint256 indexed id, address indexed hookAddress); event GlobalRestrictionHookSet(address indexed hookAddress); event GlobalTransferableSet(bool indexed transferable); + event LegendAdded(uint256 indexed tokenId, uint256 legendIndex, bytes32 legendHash); + event LegendRemoved(uint256 indexed tokenId, uint256 legendIndex, bytes32 legendHash); + event LegendRemovalRequested(uint256 indexed tokenId, uint256 legendIndex, string justification); + event DefaultLegendAdded(uint256 legendIndex, bytes32 legendHash); + event DefaultLegendRemoved(uint256 legendIndex, bytes32 legendHash); modifier onlyIssuanceManager() { @@ -97,6 +102,12 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { _; } + modifier onlyIssuanceManagerOrExtension() { + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + if (msg.sender != s.issuanceManager && msg.sender != s.extension) revert NotIssuanceManager(); + _; + } + constructor() { _disableInitializers(); } @@ -109,6 +120,9 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); s.issuanceManager = _issuanceManager; s.defaultLegend = _defaultLegend; + for (uint256 i = 0; i < _defaultLegend.length; i++) { + s.defaultLegendHashes.push(keccak256(bytes(_defaultLegend[i]))); + } s.securityType = _securityType; s.securitySeries = _securitySeries; s.certificateUri = _certificateUri; @@ -144,22 +158,28 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { ) external onlyIssuanceManager returns (uint256) { _safeMint(to, tokenId); - CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId] = CyberCertPrinterStorage.cyberCertStorage().defaultLegend; - CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details; + _ensureDefaultLegendHashes(); + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + s.certLegend[tokenId] = s.defaultLegend; + s.certLegendHashes[tokenId] = s.defaultLegendHashes; + s.certificateDetails[tokenId] = details; emit CyberCertPrinter_CertificateCreated(tokenId); return tokenId; } // Restricted minting with full agreement details function safeMintAndAssign( - address to, + address to, uint256 tokenId, CertificateDetails memory details ) external onlyIssuanceManager returns (uint256) { _safeMint(to, tokenId); - CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId] = CyberCertPrinterStorage.cyberCertStorage().defaultLegend; + _ensureDefaultLegendHashes(); + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + s.certLegend[tokenId] = s.defaultLegend; + s.certLegendHashes[tokenId] = s.defaultLegendHashes; // Store agreement details - CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details; + s.certificateDetails[tokenId] = details; string memory issuerName = IIssuanceManager(CyberCertPrinterStorage.cyberCertStorage().issuanceManager).companyName(); emit CyberCertPrinter_CertificateCreated(tokenId); return tokenId; @@ -383,22 +403,53 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { return CyberCertPrinterStorage.cyberCertStorage().endorsementRequired; } + /// @dev Backfill defaultLegendHashes for proxies deployed before hash tracking was added. + function _ensureDefaultLegendHashes() internal { + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + uint256 textLen = s.defaultLegend.length; + uint256 hashLen = s.defaultLegendHashes.length; + if (hashLen < textLen) { + for (uint256 i = hashLen; i < textLen; i++) { + s.defaultLegendHashes.push(keccak256(bytes(s.defaultLegend[i]))); + } + } + } + + /// @dev Backfill certLegendHashes for tokens minted before hash tracking was added. + function _ensureCertLegendHashes(uint256 tokenId) internal { + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + uint256 textLen = s.certLegend[tokenId].length; + uint256 hashLen = s.certLegendHashes[tokenId].length; + if (hashLen < textLen) { + for (uint256 i = hashLen; i < textLen; i++) { + s.certLegendHashes[tokenId].push(keccak256(bytes(s.certLegend[tokenId][i]))); + } + } + } + function addDefaultLegend(string memory newLegend) external onlyIssuanceManager { + _ensureDefaultLegendHashes(); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + bytes32 h = keccak256(bytes(newLegend)); s.defaultLegend.push(newLegend); + s.defaultLegendHashes.push(h); + emit DefaultLegendAdded(s.defaultLegend.length - 1, h); } function removeDefaultLegendAt(uint256 index) external onlyIssuanceManager { + _ensureDefaultLegendHashes(); 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 + bytes32 removedHash = s.defaultLegendHashes[index]; uint256 lastIndex = s.defaultLegend.length - 1; if (index != lastIndex) { s.defaultLegend[index] = s.defaultLegend[lastIndex]; + s.defaultLegendHashes[index] = s.defaultLegendHashes[lastIndex]; } s.defaultLegend.pop(); + s.defaultLegendHashes.pop(); + emit DefaultLegendRemoved(index, removedHash); } function getDefaultLegendAt(uint256 index) external view returns (string memory) { @@ -412,35 +463,55 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { return CyberCertPrinterStorage.cyberCertStorage().defaultLegend.length; } - function addCertLegend(uint256 tokenId, string memory newLegend) external onlyIssuanceManager { + function addCertLegend(uint256 tokenId, string memory newLegend) external onlyIssuanceManagerOrExtension { + _ensureCertLegendHashes(tokenId); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + bytes32 h = keccak256(bytes(newLegend)); s.certLegend[tokenId].push(newLegend); + s.certLegendHashes[tokenId].push(h); + emit LegendAdded(tokenId, s.certLegend[tokenId].length - 1, h); } function removeCertLegendAt(uint256 tokenId, uint256 index) external onlyIssuanceManager { + _ensureCertLegendHashes(tokenId); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); if (index >= s.certLegend[tokenId].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 + bytes32 removedHash = s.certLegendHashes[tokenId][index]; uint256 lastIndex = s.certLegend[tokenId].length - 1; if (index != lastIndex) { s.certLegend[tokenId][index] = s.certLegend[tokenId][lastIndex]; + s.certLegendHashes[tokenId][index] = s.certLegendHashes[tokenId][lastIndex]; } s.certLegend[tokenId].pop(); - } + s.certLegendHashes[tokenId].pop(); + emit LegendRemoved(tokenId, index, removedHash); + } + + /// @notice Request legend removal (informational audit trail; actual removal requires removeCertLegendAt) + function requestCertLegendRemoval(uint256 tokenId, uint256 legendIndex, string calldata justification) external { + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + if (legendIndex >= s.certLegend[tokenId].length) revert InvalidLegendIndex(); + emit LegendRemovalRequested(tokenId, legendIndex, justification); + } function getCertLegendAt(uint256 tokenId, uint256 index) external view returns (string memory) { CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); if (index >= s.certLegend[tokenId].length) revert InvalidLegendIndex(); - + return s.certLegend[tokenId][index]; - } + } function getCertLegendCount(uint256 tokenId) external view returns (uint256) { return CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId].length; } + /// @notice Get all legends and their hashes for a certificate + function getCertLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes) { + CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); + return (s.certLegend[tokenId], s.certLegendHashes[tokenId]); + } + function getExtension(uint256 tokenId) external view returns (address) { return CyberCertPrinterStorage.cyberCertStorage().extension; } diff --git a/src/IssuanceManager.sol b/src/IssuanceManager.sol index 9cc5cf38..2b0602ad 100644 --- a/src/IssuanceManager.sol +++ b/src/IssuanceManager.sol @@ -56,6 +56,12 @@ import "./interfaces/ICertificateConverter.sol"; import "./interfaces/IIssuanceManagerFactory.sol"; import "./storage/IssuanceManagerStorage.sol"; +/// @dev Minimal interface for extension legend seeding at mint time. +interface ILegendSeeder { + function initializeLegends(uint256 tokenId, bytes32 seriesId) external; + function setCertPrinter(address _certPrinter) external; +} + /// @title IssuanceManager /// @notice Manages the issuance and lifecycle of digital certificates representing securities and more /// @dev Implements UUPS upgradeable pattern and BorgAuth access control @@ -197,6 +203,9 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable { _securitySeries, _extension ); + if (_extension != address(0)) { + try ILegendSeeder(_extension).setCertPrinter(newCert) {} catch {} + } emit CertPrinterCreated( newCert, IssuanceManagerStorage.getCORP(), @@ -224,6 +233,7 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable { ICyberCertPrinter cert = ICyberCertPrinter(certAddress); uint256 tokenId = cert.totalSupply(); uint256 id = cert.safeMint(tokenId, to, _details); + _seedSeriesLegends(certAddress, tokenId, _details.extensionData); string memory tokenURI = cert.tokenURI(tokenId); emit CertificateCreated( tokenId, @@ -273,6 +283,7 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable { tokenId = cert.totalSupply(); cert.safeMintAndAssign(investor, tokenId, _details); + _seedSeriesLegends(certAddress, tokenId, _details.extensionData); string memory tokenURI = cert.tokenURI(tokenId); emit CertificateCreated( tokenId, @@ -285,6 +296,21 @@ contract IssuanceManager is Initializable, BorgAuthACL, UUPSUpgradeable { return tokenId; } + /// @dev If the cert has a share extension with series-specific transfer restrictions, + /// seed those restriction texts as certificate legends. Silently skips if the + /// extension doesn't support initializeLegends (e.g. SAFT, TokenWarrant). + function _seedSeriesLegends(address certAddress, uint256 tokenId, bytes memory extensionData) internal { + if (extensionData.length == 0) return; + address ext = ICyberCertPrinter(certAddress).getExtension(tokenId); + if (ext == address(0)) return; + // First 32 bytes of the ABI-encoded CertificateData is the seriesId + bytes32 seriesId; + // solhint-disable-next-line no-inline-assembly + assembly { seriesId := mload(add(extensionData, 32)) } + if (seriesId == bytes32(0)) return; + try ILegendSeeder(ext).initializeLegends(tokenId, seriesId) {} catch {} + } + /// @notice Adds an issuer's signature to a certificate /// @dev Only callable by admin, requires valid signature URI /// @param certAddress Address of the certificate printer contract diff --git a/src/interfaces/ICyberCertPrinter.sol b/src/interfaces/ICyberCertPrinter.sol index 00c1d06f..d92d91a1 100644 --- a/src/interfaces/ICyberCertPrinter.sol +++ b/src/interfaces/ICyberCertPrinter.sol @@ -126,4 +126,5 @@ interface ICyberCertPrinter is IERC721 { function totalSupply() external view returns (uint256); function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); function setTokenTransferable(uint256 tokenId, bool value) external; + function getExtension(uint256 tokenId) external view returns (address); } diff --git a/src/libs/StringUtils.sol b/src/libs/StringUtils.sol new file mode 100644 index 00000000..f4b1d2ee --- /dev/null +++ b/src/libs/StringUtils.sol @@ -0,0 +1,27 @@ +pragma solidity 0.8.28; + +/// @title StringUtils +/// @notice Shared string conversion utilities for CyberCorps extensions +library StringUtils { + function uint256ToString(uint256 _i) internal pure returns (string memory) { + if (_i == 0) { + return "0"; + } + uint256 j = _i; + uint256 len; + while (j != 0) { + len++; + j /= 10; + } + bytes memory bstr = new bytes(len); + uint256 k = len; + while (_i != 0) { + k = k - 1; + uint8 temp = uint8(48 + (_i % 10)); + bytes1 b1 = bytes1(temp); + bstr[k] = b1; + _i /= 10; + } + return string(bstr); + } +} diff --git a/src/storage/CyberCertPrinterStorage.sol b/src/storage/CyberCertPrinterStorage.sol index f3168253..869722c2 100644 --- a/src/storage/CyberCertPrinterStorage.sol +++ b/src/storage/CyberCertPrinterStorage.sol @@ -99,7 +99,8 @@ library CyberCertPrinterStorage { bool endorsementRequired; // New variables must be appended below to preserve storage layout for upgrades mapping(uint256 => bool) tokenTransferable; - + mapping(uint256 => bytes32[]) certLegendHashes; + bytes32[] defaultLegendHashes; } // Returns the storage layout diff --git a/src/storage/extensions/SAFTEExtension.sol b/src/storage/extensions/SAFTEExtension.sol index 71659de5..166f9b88 100644 --- a/src/storage/extensions/SAFTEExtension.sol +++ b/src/storage/extensions/SAFTEExtension.sol @@ -45,6 +45,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; +import "../../libs/StringUtils.sol"; struct SAFTEData { UnlockStartTimeType unlockStartTimeType; @@ -60,6 +61,8 @@ struct SAFTEData { } contract SAFTEExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + using StringUtils for uint256; + bytes32 public constant EXTENSION_TYPE = keccak256("SAFTE"); uint256 public constant PERCENTAGE_PRECISION = 10 ** 4; @@ -88,45 +91,22 @@ contract SAFTEExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { string memory json = string(abi.encodePacked( ', "SAFTEDetails": {', - '"protocolUSDValuationAtTimeofInvestment": "', uint256ToString(decoded.protocolUSDValuationAtTimeofInvestment), + '"protocolUSDValuationAtTimeofInvestment": "', decoded.protocolUSDValuationAtTimeofInvestment.uint256ToString(), '", "unlockStartTimeType": "', UnlockStartTimeTypeToString(decoded.unlockStartTimeType), - '", "unlockStartTime": "', uint256ToString(decoded.unlockStartTime), - '", "unlockingPeriod": "', uint256ToString(decoded.unlockingPeriod), - '", "unlockingCliffPeriod": "', uint256ToString(decoded.unlockingCliffPeriod), - '", "unlockingCliffPercentage": "', uint256ToString(decoded.unlockingCliffPercentage), + '", "unlockStartTime": "', decoded.unlockStartTime.uint256ToString(), + '", "unlockingPeriod": "', decoded.unlockingPeriod.uint256ToString(), + '", "unlockingCliffPeriod": "', decoded.unlockingCliffPeriod.uint256ToString(), + '", "unlockingCliffPercentage": "', decoded.unlockingCliffPercentage.uint256ToString(), '", "unlockingIntervalType": "', UnlockingIntervalTypeToString(decoded.unlockingIntervalType), '", "tokenCalculationMethod": "', conversionTypeToString(decoded.tokenCalculationMethod), - '", "minCompanyReserve": "', uint256ToString(decoded.minCompanyReserve), - '", "tokenPremiumMultiplier": "', uint256ToString(decoded.tokenPremiumMultiplier), + '", "minCompanyReserve": "', decoded.minCompanyReserve.uint256ToString(), + '", "tokenPremiumMultiplier": "', decoded.tokenPremiumMultiplier.uint256ToString(), '"}' )); return json; } - // Helper function to convert uint256 to string - function uint256ToString(uint256 _i) internal pure returns (string memory) { - if (_i == 0) { - return "0"; - } - uint256 j = _i; - uint256 len; - while (j != 0) { - len++; - j /= 10; - } - bytes memory bstr = new bytes(len); - uint256 k = len; - while (_i != 0) { - k = k-1; - uint8 temp = uint8(48 + (_i % 10)); - bytes1 b1 = bytes1(temp); - bstr[k] = b1; - _i /= 10; - } - return string(bstr); - } - function conversionTypeToString(TokenCalculationMethod _type) internal pure returns (string memory) { if (_type == TokenCalculationMethod.equityProRataToCompanyReserve) return "equityProRataToCompanyReserve"; if (_type == TokenCalculationMethod.equityProRataToTokenSupply) return "equityProRataToTokenSupply"; diff --git a/src/storage/extensions/SAFTExtension.sol b/src/storage/extensions/SAFTExtension.sol index 032afb64..cb05a243 100644 --- a/src/storage/extensions/SAFTExtension.sol +++ b/src/storage/extensions/SAFTExtension.sol @@ -45,6 +45,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; +import "../../libs/StringUtils.sol"; struct SAFTData { UnlockStartTimeType unlockStartTimeType; // enum of different types, can be agreementExecutionTime, tgeTime, or setTime @@ -56,6 +57,8 @@ struct SAFTData { } contract SAFTExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + using StringUtils for uint256; + bytes32 public constant EXTENSION_TYPE = keccak256("SAFT"); uint256 public constant PERCENTAGE_PRECISION = 10 ** 4; @@ -85,10 +88,10 @@ contract SAFTExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { string memory json = string(abi.encodePacked( ', "SAFTDetails": {', '"unlockStartTimeType": "', UnlockStartTimeTypeToString(decoded.unlockStartTimeType), - '", "unlockStartTime": "', uint256ToString(decoded.unlockStartTime), - '", "unlockingPeriod": "', uint256ToString(decoded.unlockingPeriod), - '", "unlockingCliffPeriod": "', uint256ToString(decoded.unlockingCliffPeriod), - '", "unlockingCliffPercentage": "', uint256ToString(decoded.unlockingCliffPercentage), + '", "unlockStartTime": "', decoded.unlockStartTime.uint256ToString(), + '", "unlockingPeriod": "', decoded.unlockingPeriod.uint256ToString(), + '", "unlockingCliffPeriod": "', decoded.unlockingCliffPeriod.uint256ToString(), + '", "unlockingCliffPercentage": "', decoded.unlockingCliffPercentage.uint256ToString(), '", "unlockingIntervalType": "', UnlockingIntervalTypeToString(decoded.unlockingIntervalType), '"}' )); @@ -96,29 +99,6 @@ contract SAFTExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { return json; } - // Helper function to convert uint256 to string - function uint256ToString(uint256 _i) internal pure returns (string memory) { - if (_i == 0) { - return "0"; - } - uint256 j = _i; - uint256 len; - while (j != 0) { - len++; - j /= 10; - } - bytes memory bstr = new bytes(len); - uint256 k = len; - while (_i != 0) { - k = k-1; - uint8 temp = uint8(48 + (_i % 10)); - bytes1 b1 = bytes1(temp); - bstr[k] = b1; - _i /= 10; - } - return string(bstr); - } - function UnlockStartTimeTypeToString(UnlockStartTimeType _type) internal pure returns (string memory) { if (_type == UnlockStartTimeType.tokenWarrantTime) return "agreementExecutionTime"; if (_type == UnlockStartTimeType.tgeTime) return "tgeTime"; diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 970d3166..d55b594e 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -1,32 +1,32 @@ -/* .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 +/* .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") @@ -34,9 +34,9 @@ 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, +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, +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; @@ -45,17 +45,22 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; +import "../../libs/StringUtils.sol"; -/// @notice Classification of capital stock -enum ShareClass { - Common, - Preferred +/// @notice Minimal interface for CyberCertPrinter legend operations +interface ICertPrinterLegends { + function addCertLegend(uint256 tokenId, string memory newLegend) external; + function getCertLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes); } +// ══════════════════════════════════════════════════════════════════════════════ +// Enums +// ══════════════════════════════════════════════════════════════════════════════ + /// @notice Liquidation preference payout structure enum LiquidationPreferenceType { - NonParticipating, // holder receives the greater of preference or as-converted amount - Participating, // holder receives preference + pro rata share of remainder + NonParticipating, // single-dip: greater of preference or as-converted + Participating, // double-dip: preference + pro rata share of remainder CappedParticipating // participating, but capped at a multiple of original issue price } @@ -71,156 +76,971 @@ enum AntiDilutionType { enum DividendType { None, NonCumulative, // dividends declared at board discretion, unpaid amounts do not accrue - Cumulative // dividends accrue whether or not declared + Cumulative // dividends accrue whether or not declared } -/// @notice Transfer restriction regime applicable to the shares +/// @notice Transfer restriction regime applicable to shares. enum TransferRestrictionType { None, BoardConsentRequired, // Section 8.9(a) of Bylaws — no transfer without board consent ROFRAndCoSale, // subject to ROFR/Co-Sale agreement - Rule144Eligible, // restriction relaxed per Rule 144 + LockUp, // time-based lock-up restriction + SecuritiesActRestriction, // Securities Act restricted legend (standard "not registered" legend) CustomRestriction // custom restriction defined by agreement or board resolution } -/// @notice Core share designation data for a cyberCERT representing equity -struct ShareData { - // === Identity & Classification === - ShareClass shareClass; // Common or Preferred - string seriesName; // e.g. "Series Seed", "" for Common - uint256 parValue; // par value per share (18 decimals, e.g. 10000000000000 = $0.00001) +/// @notice Redemption mechanism type +enum RedemptionType { + None, + HolderOptional, + CompanyOptional, + Mandatory, + EventTriggered +} + +/// @notice Types of mandatory/automatic conversion triggers +enum MandatoryConversionTriggerType { + QualifiedIPO, + ClassVote, + DeemedLiquidation, + Custom +} + +/// @notice Scope of a voting right — distinguishes class-wide votes from series-specific votes. +/// Under DGCL section 151(a), the certificate of incorporation may provide that holders of any +/// class or series shall vote as a separate class or series. +enum VotingScope { + ClassWide, // all series sharing the same shareClassKey vote together + SeriesSpecific // only this series votes +} + +/// @notice Share representation form (DGCL §158) +enum ShareRepresentationType { + Certificated, // traditional paper or PDF certificate + Uncertificated, // book-entry (DGCL §158 uncertificated shares) + Tokenized // on-chain tokenized representation +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Structs +// ══════════════════════════════════════════════════════════════════════════════ - // === Economic Rights === +/// @notice Mandatory/automatic conversion trigger definition. +/// Supports compound conditions (e.g., QualifiedIPO requires price AND proceeds AND listing). +struct MandatoryConversionTrigger { + MandatoryConversionTriggerType triggerType; + uint256 primaryThreshold; // e.g., IPO price per share threshold (18 dec) + uint256 secondaryThreshold; // e.g., minimum aggregate proceeds threshold (18 dec); 0 if single-condition + string additionalConditions; // human-readable additional conditions (e.g., "listed on NYSE or NASDAQ") + string description; // human-readable description of the full trigger condition +} + +/// @notice Matter-specific voting right (used for protective provisions and special class/series votes) +struct SpecialVotingRight { + bytes32 matterType; // e.g., MATTER_CHARTER_AMENDMENT, MATTER_MERGER_APPROVAL, etc. + uint256 votesPerShare; // votes per share for this specific matter (18 decimals) + uint256 threshold; // approval threshold (4 decimal percentage, e.g. 5010 = 50.1%) + bool isVetoRight; // true = blocking/consent right rather than affirmative vote + VotingScope scope; // whether this right is exercised at the class level or series level + string description; // human-readable description +} + +/// @notice Exception to a transfer restriction (Bylaws section 8.9(b) compliance) +struct TransferRestrictionException { + bytes32 exceptionType; // e.g., keccak256("ESTATE_PLANNING_TRANSFER"), keccak256("AFFILIATE_TRANSFER") + string exceptionText; // human-readable description of the exception + bool requiresEvidence; // whether evidence must be presented for this exception to apply +} + +/// @notice A single transfer restriction with full legal text (DGCL section 202 compliance) +struct TransferRestriction { + TransferRestrictionType restrictionType; + string restrictionText; // actual legal legend / restriction notice text + string sourceAgreement; // pointer to imposing agreement (e.g., "Bylaws section 8.9") + bool isRemovable; // whether this restriction can be removed (e.g., Rule 144 legend removal) + TransferRestrictionException[] exceptions; // carved-out exceptions to this restriction +} + +/// @notice Record of a stock split applied to a series +struct SplitRecord { + uint256 numerator; // split ratio numerator (e.g., 10000 for a 10,000:1 split) + uint256 denominator; // split ratio denominator (e.g., 1) + uint256 timestamp; // when the split was recorded + string sourceAuthorityURI; // pointer to the board resolution or charter amendment authorizing the split +} + +/// @notice Canonical series-wide terms, stored once per class/series. +/// All price/value fields use 18-decimal precision. +/// Percentage fields use 4-decimal precision (10**4 basis, 5010 = 50.10%). +/// NOTE: Dynamic arrays (mandatoryConversionTriggers, specialVotingRights, transferRestrictions) +/// are stored in separate mappings and managed via dedicated CRUD functions. +struct SeriesTerms { + // --- Identity & Classification --- + bytes32 shareClassKey; // extensible class identifier (use CLASS_COMMON, CLASS_PREFERRED, or custom) + string seriesName; // human-readable display name + uint256 parValue; // par value per share (18 decimals) + uint256 authorizedShares; // total authorized shares for this class/series uint256 originalIssuePrice; // price per share at issuance (18 decimals) - uint256 liquidationPreferenceMultiple; // multiple of OIP for preference (18 decimals, 1e18 = 1x) + uint256 effectiveDate; // timestamp when these terms became effective + string sourceAuthorityURI; // pointer to charter provision, board resolution, or amendment + + // --- Liquidation Preference & Seniority --- + uint256 liquidationPreferenceMultiple; // multiple of OIP (18 decimals; 1e18 = 1x) LiquidationPreferenceType liquidationPreferenceType; uint256 participationCap; // if CappedParticipating, max multiple of OIP (18 decimals); 0 otherwise + uint256 seniorityRank; // lower = more senior; equal rank = pari passu + + // --- Dividends --- DividendType dividendType; - uint256 dividendRateOrPriority; // annual rate (18 decimals, e.g. 8% = 8e16) or 0 for pari passu with common + uint256 dividendRate; // annual rate (18 decimals, 8% = 8e16); 0 if None + uint256 dividendAccrualStartDate; // timestamp; relevant for cumulative dividends + bool dividendCompounding; // true = compound accrual; false = simple accrual + bool dividendIncreasesLiquidationAmount; // whether accrued unpaid dividends add to liquidation preference - // === Conversion === - bool isConvertible; // whether shares are convertible to Common - uint256 conversionPrice; // conversion price (18 decimals); conversion ratio = OIP / conversionPrice + // --- Conversion --- + bool isConvertible; + bytes32 targetConversionSeriesId; // seriesId of the target class/series for conversion + uint256 conversionPrice; // current conversion price (18 decimals); ratio = OIP / conversionPrice AntiDilutionType antiDilutionType; + bool allowsFractionalConversion; // whether fractional shares may be issued on conversion + bool hasMandatoryConversion; + // NOTE: mandatoryConversionTriggers stored separately in _conversionTriggers mapping - // === Voting === - uint256 votesPerShare; // votes per share (18 decimals, 1e18 = 1 vote per share); 0 = non-voting - bool hasClassVotingRights; // whether holder votes as a separate class on certain matters - uint8 designatedBoardSeats; // number of board seats this class/series is entitled to elect + // --- Voting --- + uint256 votesPerShare; // default votes per share (18 decimals, 1e18 = 1 vote); 0 = non-voting + uint8 designatedBoardSeats; // board seats this series is entitled to elect + bool hasClassVotingRights; // whether this series participates in class-wide separate votes + bool hasSeriesVotingRights; // whether this series can vote separately as its own series + // NOTE: specialVotingRights stored separately in _specialVotingRights mapping - // === Transfer Restrictions === - TransferRestrictionType transferRestrictionType; + // --- Transfer Restrictions --- + // NOTE: transferRestrictions stored separately in _transferRestrictions mapping - // === Redemption === - bool isRedeemable; // whether shares are subject to optional or mandatory redemption - uint256 redemptionPrice; // redemption price per share (18 decimals); 0 if not redeemable + // --- Redemption --- + bool isRedeemable; + RedemptionType redemptionType; + uint256 redemptionPrice; // redemption price per share (18 decimals) + string redemptionSchedule; // human-readable or URI to redemption schedule/terms + string redemptionTriggerDescription; // for EventTriggered: description of the triggering event - // === Protective Provisions === - bool hasProtectiveProvisions; // whether holders have protective provision veto rights (COI §3.3) - uint256 protectiveProvisionThreshold; // percentage of outstanding required to waive (4 decimals, e.g. 5010 = 50.10%) + // --- NVCA Optional Fields --- + bool hasPayToPlay; // whether pay-to-play provisions apply + string payToPlayTermsURI; // pointer to pay-to-play terms + bool hasRegistrationRights; // whether registration rights exist + string registrationRightsURI; // pointer to registration rights agreement + bool hasProRataRights; // whether pro-rata participation rights exist + bool hasInformationRights; // whether information rights exist + bool hasDragAlongRights; // whether drag-along rights exist + string dragAlongTermsURI; // pointer to drag-along terms +} - // === Authorization === - uint256 authorizedShares; // total authorized shares of this class/series +/// @notice Per-certificate metadata. References canonical SeriesTerms via seriesId. +/// Legends are stored in dedicated mappings, not in this struct, to allow individual add/remove. +struct CertificateData { + bytes32 seriesId; // pointer to canonical SeriesTerms + uint256 certificateNumber; // unique certificate number (may equal token ID but explicitly tracked) + uint256 numberOfShares; // number of shares represented by this certificate + uint256 issueDate; // timestamp of issuance + bool isPartlyPaid; // whether shares are partly paid (Bylaws section 8.5, DGCL section 156) + uint256 amountPaid; // if partly paid, amount actually paid (18 decimals) + uint256 totalConsideration; // if partly paid, total consideration to be paid (18 decimals) + string sourceAuthorityURI; // per-certificate pointer to board resolution, subscription agreement, etc. + ShareRepresentationType representationType; // form of share representation (DGCL §158) + uint256 holdingPeriodStartDate; // Rule 144(d) holding period start (may differ from issueDate due to tacking) + bool holdingPeriodTackingApplied; // whether tacking was applied to the holding period } +// ══════════════════════════════════════════════════════════════════════════════ +// Contract +// ══════════════════════════════════════════════════════════════════════════════ + +/// @notice NOTE: This contract has NO prior production deployment. No proxies or storage layouts +/// from previous versions exist on any chain. Storage slots can be freely re-ordered +/// without upgrade-compatibility concerns. contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + using StringUtils for uint256; + + // ────────────────────────────────────────────────────────────── + // Constants + // ────────────────────────────────────────────────────────────── + bytes32 public constant EXTENSION_TYPE = keccak256("SHARE"); uint256 public constant PERCENTAGE_PRECISION = 10 ** 4; uint256 public constant PRICE_PRECISION = 10 ** 18; - // offset to leave for future upgrades - uint256[30] private __gap; + /// @notice Extensible share class identifiers (use arbitrary bytes32 for exotic classes) + bytes32 public constant CLASS_COMMON = keccak256("COMMON"); + bytes32 public constant CLASS_PREFERRED = keccak256("PREFERRED"); + + /// @notice Protective provision matter type constants (COI §3.3 categories) + bytes32 public constant MATTER_CHARTER_AMENDMENT = keccak256("CHARTER_AMENDMENT"); + bytes32 public constant MATTER_MERGER_APPROVAL = keccak256("MERGER_APPROVAL"); + bytes32 public constant MATTER_ASSET_SALE = keccak256("ASSET_SALE"); + bytes32 public constant MATTER_NEW_SERIES_ISSUANCE = keccak256("NEW_SERIES_ISSUANCE"); + bytes32 public constant MATTER_DIVIDEND_DECLARATION = keccak256("DIVIDEND_DECLARATION"); + bytes32 public constant MATTER_LIQUIDATION = keccak256("LIQUIDATION"); + bytes32 public constant MATTER_DEBT_INCURRENCE = keccak256("DEBT_INCURRENCE"); + bytes32 public constant MATTER_RELATED_PARTY_TRANSACTION = keccak256("RELATED_PARTY_TRANSACTION"); + + /// @notice Standard Securities Act restricted legend text (Rule 144 / §5 compliance) + string public constant SECURITIES_ACT_LEGEND = + "THE SECURITIES REPRESENTED HEREBY HAVE NOT BEEN REGISTERED UNDER THE SECURITIES ACT OF 1933, " + "AS AMENDED (THE \"ACT\"), OR UNDER THE SECURITIES LAWS OF ANY STATE. THESE SECURITIES ARE " + "SUBJECT TO RESTRICTIONS ON TRANSFERABILITY AND RESALE AND MAY NOT BE TRANSFERRED OR RESOLD " + "EXCEPT AS PERMITTED UNDER THE ACT AND APPLICABLE STATE SECURITIES LAWS, PURSUANT TO " + "REGISTRATION OR EXEMPTION THEREFROM."; + + // ────────────────────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────────────────────── + + event SeriesCreated(bytes32 indexed seriesId, bytes32 indexed shareClassKey, string seriesName); + event SeriesTermsUpdated(bytes32 indexed seriesId, uint256 newVersion, bytes32 oldTermsHash); + event ConversionPriceAdjusted(bytes32 indexed seriesId, uint256 oldPrice, uint256 newPrice); + event AuthorizedSharesChanged(bytes32 indexed seriesId, uint256 oldAmount, uint256 newAmount); + event StockSplitRecorded( + bytes32 indexed seriesId, uint256 numerator, uint256 denominator, + uint256 oldOIP, uint256 newOIP, uint256 oldParValue, uint256 newParValue + ); + event ConversionTriggerAdded(bytes32 indexed seriesId, uint256 index); + event ConversionTriggerRemoved(bytes32 indexed seriesId, uint256 index); + event SpecialVotingRightAdded(bytes32 indexed seriesId, uint256 index, bytes32 matterType); + event SpecialVotingRightRemoved(bytes32 indexed seriesId, uint256 index, bytes32 matterType); + event TransferRestrictionAdded(bytes32 indexed seriesId, uint256 index); + event TransferRestrictionRemoved(bytes32 indexed seriesId, uint256 index); + event IssuerNameUpdated(string oldName, string newName); + event StateOfIncorporationUpdated(string oldState, string newState); + + // ────────────────────────────────────────────────────────────── + // State variables + // NOTE: These occupy the same storage slots as the former __gap[30]. + // Slots used + reserved = 30 total (preserves layout). + // ────────────────────────────────────────────────────────────── + + /// @notice Canonical series terms registry + mapping(bytes32 => SeriesTerms) internal _seriesRegistry; // slot 0 + /// @notice Ordered list of series IDs for enumeration + bytes32[] public seriesIds; // slot 1 + /// @notice O(1) existence check for series + mapping(bytes32 => bool) public seriesExists; // slot 2 + /// @notice Issuer name — changeable by board/owner (covers name changes, mergers) + string public issuerName; // slot 3 + + // --- Separated dynamic arrays (slots 4-11) --- + + /// @notice Separated dynamic arrays: mandatory conversion triggers per series + mapping(bytes32 => MandatoryConversionTrigger[]) internal _conversionTriggers; // slot 4 + /// @notice Separated dynamic arrays: special voting rights per series + mapping(bytes32 => SpecialVotingRight[]) internal _specialVotingRights; // slot 5 + /// @notice Separated dynamic arrays: transfer restrictions per series + mapping(bytes32 => TransferRestriction[]) internal _transferRestrictions; // slot 6 + /// @notice Series terms version counter (incremented on each update) + mapping(bytes32 => uint256) public seriesTermsVersion; // slot 7 + /// @notice Historical terms hashes: seriesId => version => keccak256 of old terms + mapping(bytes32 => mapping(uint256 => bytes32)) public seriesTermsHistoryHashes; // slot 8 + /// @notice Stock split history per series + mapping(bytes32 => SplitRecord[]) internal _splitHistory; // slot 9 + /// @notice State of incorporation (DGCL §158 compliance) + string public stateOfIncorporation; // slot 10 + /// @notice CyberCertPrinter address — canonical legend storage owner + address public certPrinter; // slot 11 + + /// @dev Reserved storage for future upgrades + uint256[16] private __gap; // slots 14-29 + + // ────────────────────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────────────────────── function initialize(address _auth) external initializer { __UUPSUpgradeable_init(); __BorgAuthACL_init(_auth); } - function decodeExtensionData(bytes memory data) external pure returns (ShareData memory) { - return abi.decode(data, (ShareData)); + /// @notice Set the CyberCertPrinter address for delegated legend management + function setCertPrinter(address _certPrinter) external onlyOwner { + require(_certPrinter != address(0), "ShareExtension: zero address"); + certPrinter = _certPrinter; + } + + // ══════════════════════════════════════════════════════════════ + // Series Management (onlyOwner / board-authorized) + // ══════════════════════════════════════════════════════════════ + + /// @notice Register a new series with canonical terms. + /// Dynamic arrays (triggers, voting rights, restrictions) must be added via dedicated CRUD functions after creation. + function createSeries(bytes32 seriesId, SeriesTerms memory terms) external onlyOwner { + require(seriesId != bytes32(0), "ShareExtension: zero seriesId"); + require(!seriesExists[seriesId], "ShareExtension: series already exists"); + + (bool valid, string memory err) = _validateSeriesTermsInternal(terms); + require(valid, err); + + _seriesRegistry[seriesId] = terms; + seriesIds.push(seriesId); + seriesExists[seriesId] = true; + seriesTermsVersion[seriesId] = 1; + + emit SeriesCreated(seriesId, terms.shareClassKey, terms.seriesName); + } + + /// @notice Replace the full scalar terms for an existing series. + /// Increments version, hashes old terms, emits versioned event. + function updateSeriesTerms(bytes32 seriesId, SeriesTerms memory terms) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + + (bool valid, string memory err) = _validateSeriesTermsInternal(terms); + require(valid, err); + + // Version and hash the old terms before overwriting + uint256 currentVersion = seriesTermsVersion[seriesId]; + bytes32 oldHash = keccak256(abi.encode(_seriesRegistry[seriesId])); + seriesTermsHistoryHashes[seriesId][currentVersion] = oldHash; + + _seriesRegistry[seriesId] = terms; + seriesTermsVersion[seriesId] = currentVersion + 1; + + emit SeriesTermsUpdated(seriesId, currentVersion + 1, oldHash); + } + + /// @notice Update conversion price independently (e.g., anti-dilution adjustment) + function updateConversionPrice(bytes32 seriesId, uint256 newPrice) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + SeriesTerms storage s = _seriesRegistry[seriesId]; + require(s.isConvertible, "ShareExtension: series is not convertible"); + require(newPrice > 0, "ShareExtension: conversionPrice must be > 0"); + + uint256 oldPrice = s.conversionPrice; + s.conversionPrice = newPrice; + + emit ConversionPriceAdjusted(seriesId, oldPrice, newPrice); + } + + /// @notice Update authorized shares independently (e.g., charter amendment) + function updateAuthorizedShares(bytes32 seriesId, uint256 newAmount) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + require(newAmount > 0, "ShareExtension: authorizedShares must be > 0"); + + SeriesTerms storage s = _seriesRegistry[seriesId]; + uint256 oldAmount = s.authorizedShares; + s.authorizedShares = newAmount; + + emit AuthorizedSharesChanged(seriesId, oldAmount, newAmount); + } + + /// @notice Update series display name + function updateSeriesName(bytes32 seriesId, string calldata newName) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + _seriesRegistry[seriesId].seriesName = newName; + } + + // ══════════════════════════════════════════════════════════════ + // Stock Split Management + // ══════════════════════════════════════════════════════════════ + + /// @notice Record a stock split and atomically adjust all price/share fields. + /// @param seriesId The series to adjust + /// @param splitNumerator The numerator of the split ratio (e.g., 10000 for 10,000:1) + /// @param splitDenominator The denominator of the split ratio (e.g., 1) + /// @param sourceAuthorityURI Pointer to the board resolution or charter amendment + function recordStockSplit( + bytes32 seriesId, + uint256 splitNumerator, + uint256 splitDenominator, + string calldata sourceAuthorityURI + ) external onlyOwner { + _recordStockSplit(seriesId, splitNumerator, splitDenominator, sourceAuthorityURI); + } + + /// @notice Record a stock split across multiple series (e.g., class-wide split) + function recordStockSplitBatch( + bytes32[] calldata _seriesIds, + uint256 splitNumerator, + uint256 splitDenominator, + string calldata sourceAuthorityURI + ) external onlyOwner { + for (uint256 i = 0; i < _seriesIds.length; i++) { + _recordStockSplit(_seriesIds[i], splitNumerator, splitDenominator, sourceAuthorityURI); + } + } + + function _recordStockSplit( + bytes32 seriesId, + uint256 splitNumerator, + uint256 splitDenominator, + string calldata sourceAuthorityURI + ) internal { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + require(splitNumerator > 0 && splitDenominator > 0, "ShareExtension: split ratio must be non-zero"); + require(splitNumerator != splitDenominator, "ShareExtension: split ratio must differ from 1:1"); + + SeriesTerms storage s = _seriesRegistry[seriesId]; + + uint256 oldOIP = s.originalIssuePrice; + uint256 oldParValue = s.parValue; + + // Adjust price fields DOWN by numerator/denominator (more shares = lower per-share price) + s.originalIssuePrice = (s.originalIssuePrice * splitDenominator) / splitNumerator; + s.parValue = (s.parValue * splitDenominator) / splitNumerator; + if (s.conversionPrice > 0) { + s.conversionPrice = (s.conversionPrice * splitDenominator) / splitNumerator; + } + if (s.redemptionPrice > 0) { + s.redemptionPrice = (s.redemptionPrice * splitDenominator) / splitNumerator; + } + + // Adjust share count UP + s.authorizedShares = (s.authorizedShares * splitNumerator) / splitDenominator; + + // Adjust conversion trigger thresholds — only price-denominated triggers scale with splits + MandatoryConversionTrigger[] storage triggers = _conversionTriggers[seriesId]; + for (uint256 i = 0; i < triggers.length; i++) { + if (triggers[i].triggerType == MandatoryConversionTriggerType.QualifiedIPO && triggers[i].primaryThreshold > 0) { + triggers[i].primaryThreshold = (triggers[i].primaryThreshold * splitDenominator) / splitNumerator; + } + // secondaryThreshold (e.g., aggregate proceeds) is typically not per-share, so not adjusted + } + + // Record history + _splitHistory[seriesId].push(SplitRecord({ + numerator: splitNumerator, + denominator: splitDenominator, + timestamp: block.timestamp, + sourceAuthorityURI: sourceAuthorityURI + })); + + emit StockSplitRecorded(seriesId, splitNumerator, splitDenominator, oldOIP, s.originalIssuePrice, oldParValue, s.parValue); + } + + /// @notice Get split history for a series + function getSplitHistory(bytes32 seriesId) external view returns (SplitRecord[] memory) { + return _splitHistory[seriesId]; + } + + // ══════════════════════════════════════════════════════════════ + // Dynamic Array CRUD — Conversion Triggers + // ══════════════════════════════════════════════════════════════ + + function addConversionTrigger(bytes32 seriesId, MandatoryConversionTrigger memory trigger) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + require(_seriesRegistry[seriesId].isConvertible, "ShareExtension: series is not convertible"); + _conversionTriggers[seriesId].push(trigger); + emit ConversionTriggerAdded(seriesId, _conversionTriggers[seriesId].length - 1); + } + + function removeConversionTrigger(bytes32 seriesId, uint256 index) external onlyOwner { + MandatoryConversionTrigger[] storage triggers = _conversionTriggers[seriesId]; + require(index < triggers.length, "ShareExtension: trigger index out of bounds"); + uint256 lastIdx = triggers.length - 1; + if (index != lastIdx) { + triggers[index] = triggers[lastIdx]; + } + triggers.pop(); + emit ConversionTriggerRemoved(seriesId, index); + } + + function getConversionTriggers(bytes32 seriesId) external view returns (MandatoryConversionTrigger[] memory) { + return _conversionTriggers[seriesId]; + } + + function getConversionTriggerCount(bytes32 seriesId) external view returns (uint256) { + return _conversionTriggers[seriesId].length; + } + + // ══════════════════════════════════════════════════════════════ + // Dynamic Array CRUD — Special Voting Rights + // ══════════════════════════════════════════════════════════════ + + function addSpecialVotingRight(bytes32 seriesId, SpecialVotingRight memory right) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + _specialVotingRights[seriesId].push(right); + emit SpecialVotingRightAdded(seriesId, _specialVotingRights[seriesId].length - 1, right.matterType); + } + + function removeSpecialVotingRight(bytes32 seriesId, uint256 index) external onlyOwner { + SpecialVotingRight[] storage rights = _specialVotingRights[seriesId]; + require(index < rights.length, "ShareExtension: voting right index out of bounds"); + bytes32 matterType = rights[index].matterType; + uint256 lastIdx = rights.length - 1; + if (index != lastIdx) { + rights[index] = rights[lastIdx]; + } + rights.pop(); + emit SpecialVotingRightRemoved(seriesId, index, matterType); + } + + function getSpecialVotingRights(bytes32 seriesId) external view returns (SpecialVotingRight[] memory) { + return _specialVotingRights[seriesId]; + } + + function getSpecialVotingRightCount(bytes32 seriesId) external view returns (uint256) { + return _specialVotingRights[seriesId].length; + } + + // ══════════════════════════════════════════════════════════════ + // Dynamic Array CRUD — Transfer Restrictions + // ══════════════════════════════════════════════════════════════ + + function addTransferRestriction(bytes32 seriesId, TransferRestriction memory restriction) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + _transferRestrictions[seriesId].push(); + uint256 idx = _transferRestrictions[seriesId].length - 1; + TransferRestriction storage stored = _transferRestrictions[seriesId][idx]; + stored.restrictionType = restriction.restrictionType; + stored.restrictionText = restriction.restrictionText; + stored.sourceAgreement = restriction.sourceAgreement; + stored.isRemovable = restriction.isRemovable; + for (uint256 i = 0; i < restriction.exceptions.length; i++) { + stored.exceptions.push(restriction.exceptions[i]); + } + emit TransferRestrictionAdded(seriesId, idx); + } + + function removeTransferRestriction(bytes32 seriesId, uint256 index) external onlyOwner { + TransferRestriction[] storage restrictions = _transferRestrictions[seriesId]; + require(index < restrictions.length, "ShareExtension: restriction index out of bounds"); + uint256 lastIdx = restrictions.length - 1; + if (index != lastIdx) { + // Deep copy: swap last into removed slot + TransferRestriction storage target = restrictions[index]; + TransferRestriction storage last = restrictions[lastIdx]; + target.restrictionType = last.restrictionType; + target.restrictionText = last.restrictionText; + target.sourceAgreement = last.sourceAgreement; + target.isRemovable = last.isRemovable; + // Clear and copy exceptions + delete target.exceptions; + for (uint256 i = 0; i < last.exceptions.length; i++) { + target.exceptions.push(last.exceptions[i]); + } + } + restrictions.pop(); + emit TransferRestrictionRemoved(seriesId, index); + } + + function getTransferRestrictions(bytes32 seriesId) external view returns (TransferRestriction[] memory) { + return _transferRestrictions[seriesId]; + } + + function getTransferRestrictionCount(bytes32 seriesId) external view returns (uint256) { + return _transferRestrictions[seriesId].length; } - function encodeExtensionData(ShareData memory data) external pure returns (bytes memory) { + // ══════════════════════════════════════════════════════════════ + // Certificate Data (ICertificateExtension compatibility) + // ══════════════════════════════════════════════════════════════ + + /// @notice Encode certificate data into extension bytes + function encodeCertificateData(CertificateData memory data) external pure returns (bytes memory) { return abi.encode(data); } + /// @notice Decode certificate data from extension bytes + function decodeCertificateData(bytes memory data) external pure returns (CertificateData memory) { + return abi.decode(data, (CertificateData)); + } + + // ══════════════════════════════════════════════════════════════ + // Legend Management (delegated to CyberCertPrinter) + // ══════════════════════════════════════════════════════════════ + + /// @notice Initialize a certificate's legends from its series' default transfer restrictions. + /// @dev Delegates to CyberCertPrinter.addCertLegend(). Should be called at mint time. + function initializeLegends(uint256 tokenId, bytes32 seriesId) external onlyOwner { + require(certPrinter != address(0), "ShareExtension: certPrinter not set"); + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + + ICertPrinterLegends printer = ICertPrinterLegends(certPrinter); + TransferRestriction[] storage restrictions = _transferRestrictions[seriesId]; + for (uint256 i = 0; i < restrictions.length; i++) { + if (bytes(restrictions[i].restrictionText).length > 0) { + printer.addCertLegend(tokenId, restrictions[i].restrictionText); + } + } + } + + /// @notice Get all legends for a certificate (reads from CyberCertPrinter) + function getLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes) { + require(certPrinter != address(0), "ShareExtension: certPrinter not set"); + return ICertPrinterLegends(certPrinter).getCertLegends(tokenId); + } + + // ══════════════════════════════════════════════════════════════ + // Issuer Identity + // ══════════════════════════════════════════════════════════════ + + /// @notice Set or update the issuer name + function setIssuerName(string calldata _name) external onlyOwner { + string memory oldName = issuerName; + issuerName = _name; + emit IssuerNameUpdated(oldName, _name); + } + + /// @notice Set or update the state of incorporation (DGCL §158 compliance) + function setStateOfIncorporation(string calldata _state) external onlyOwner { + string memory oldState = stateOfIncorporation; + stateOfIncorporation = _state; + emit StateOfIncorporationUpdated(oldState, _state); + } + + // ══════════════════════════════════════════════════════════════ + // Read Helpers + // ══════════════════════════════════════════════════════════════ + + /// @notice Get full series terms for a given series ID + function getSeriesTerms(bytes32 seriesId) external view returns (SeriesTerms memory) { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + return _seriesRegistry[seriesId]; + } + + /// @notice Get all registered series IDs + function getAllSeriesIds() external view returns (bytes32[] memory) { + return seriesIds; + } + + /// @notice Get the number of registered series + function getSeriesCount() external view returns (uint256) { + return seriesIds.length; + } + + /// @notice Paginated series ID retrieval + function getSeriesIdsPaginated(uint256 offset, uint256 limit) external view returns (bytes32[] memory) { + uint256 total = seriesIds.length; + if (offset >= total) { + return new bytes32[](0); + } + uint256 end = offset + limit; + if (end > total) end = total; + bytes32[] memory page = new bytes32[](end - offset); + for (uint256 i = offset; i < end; i++) { + page[i - offset] = seriesIds[i]; + } + return page; + } + + /// @notice Get full share info: series terms + decoded certificate data + legends + separated arrays + function getFullShareInfo(bytes memory certExtensionData, uint256 tokenId) + external + view + returns (SeriesTerms memory terms, CertificateData memory cert, string[] memory legends) + { + cert = abi.decode(certExtensionData, (CertificateData)); + require(seriesExists[cert.seriesId], "ShareExtension: series does not exist"); + terms = _seriesRegistry[cert.seriesId]; + if (certPrinter != address(0)) { + (legends, ) = ICertPrinterLegends(certPrinter).getCertLegends(tokenId); + } + } + + /// @notice Compute the conversion ratio for a convertible series: OIP / conversionPrice + /// @return ratio The conversion ratio (18 decimals); 0 if not convertible or conversionPrice is 0 + function getConversionRatio(bytes32 seriesId) external view returns (uint256 ratio) { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + SeriesTerms storage s = _seriesRegistry[seriesId]; + if (!s.isConvertible || s.conversionPrice == 0) return 0; + ratio = (s.originalIssuePrice * PRICE_PRECISION) / s.conversionPrice; + } + + /// @notice Compute the payment percentage for a partly-paid certificate + /// @return percentage The payment percentage (4 decimal basis, 10000 = 100%) + function getPaymentPercentage(bytes memory certExtensionData) external pure returns (uint256 percentage) { + CertificateData memory cert = abi.decode(certExtensionData, (CertificateData)); + if (!cert.isPartlyPaid || cert.totalConsideration == 0) return 10000; // fully paid + percentage = (cert.amountPaid * 10000) / cert.totalConsideration; + } + + /// @notice Compute accrued dividends for cumulative preferred shares + /// @param seriesId The series to compute for + /// @param asOfTimestamp The timestamp to compute accrual up to + /// @param numberOfShares Number of shares to compute accrual for + /// @return accrued The accrued dividend amount (18 decimals) + function computeAccruedDividends( + bytes32 seriesId, + uint256 asOfTimestamp, + uint256 numberOfShares + ) external view returns (uint256 accrued) { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + SeriesTerms storage s = _seriesRegistry[seriesId]; + if (s.dividendType != DividendType.Cumulative) return 0; + if (asOfTimestamp <= s.dividendAccrualStartDate) return 0; + + uint256 elapsed = asOfTimestamp - s.dividendAccrualStartDate; + uint256 principal = s.originalIssuePrice * numberOfShares; + + if (!s.dividendCompounding) { + // Simple accrual: rate * OIP * shares * elapsed / (365 days * 1e18) + // Rate is already in 18 decimals as a fraction (8% = 8e16) + accrued = (s.dividendRate * principal * elapsed) + / (365 days * PRICE_PRECISION); + } else { + // Compound accrual (annual compounding): + // For each full year, principal grows by (1 + rate). + // Partial year remainder accrues simple interest on the compounded principal. + uint256 fullYears = elapsed / 365 days; + uint256 remainder = elapsed % 365 days; + + uint256 compounded = principal; + for (uint256 i = 0; i < fullYears; i++) { + compounded = (compounded * (PRICE_PRECISION + s.dividendRate)) / PRICE_PRECISION; + } + + // Simple interest for the partial year on the compounded principal + if (remainder > 0) { + compounded += (compounded * s.dividendRate * remainder) / (365 days * PRICE_PRECISION); + } + + accrued = compounded - principal; + } + } + + // ══════════════════════════════════════════════════════════════ + // Validation + // ══════════════════════════════════════════════════════════════ + + /// @notice Validate series terms without modifying state + function validateSeriesTerms(SeriesTerms memory terms) external view returns (bool valid, string memory error) { + return _validateSeriesTermsInternal(terms); + } + + /// @notice Validate certificate data consistency + function validateCertificateData(CertificateData memory data) external view returns (bool valid, string memory error) { + return _validateCertificateDataInternal(data); + } + + // ══════════════════════════════════════════════════════════════ + // ICertificateExtension Overrides + // ══════════════════════════════════════════════════════════════ + function supportsExtensionType(bytes32 extensionType) external pure override returns (bool) { return extensionType == EXTENSION_TYPE; } - function getExtensionURI(bytes memory data) external pure override returns (string memory) { - ShareData memory d = abi.decode(data, (ShareData)); + /// @notice Render extension data as a JSON fragment (intended for off-chain metadata consumption). + /// @dev `view` because it reads seriesRegistry, legends, and issuerName. + function getExtensionURI(bytes memory data) external view override returns (string memory) { + if (data.length == 0) return ""; + + CertificateData memory cert = abi.decode(data, (CertificateData)); + require(seriesExists[cert.seriesId], "ShareExtension: unknown seriesId in extension data"); + + return _buildURI(cert); + } + + // ══════════════════════════════════════════════════════════════ + // Internal — Validation + // ══════════════════════════════════════════════════════════════ + + function _validateSeriesTermsInternal(SeriesTerms memory t) internal pure returns (bool, string memory) { + if (t.authorizedShares == 0) return (false, "ShareExtension: authorizedShares must be > 0"); + if (t.parValue == 0) return (false, "ShareExtension: parValue must be > 0"); + + if (!t.isConvertible) { + if (t.conversionPrice != 0) return (false, "ShareExtension: conversionPrice must be 0 when not convertible"); + if (t.targetConversionSeriesId != bytes32(0)) return (false, "ShareExtension: targetConversionSeriesId must be zero when not convertible"); + if (t.hasMandatoryConversion) return (false, "ShareExtension: hasMandatoryConversion must be false when not convertible"); + } + + if (t.isConvertible) { + if (t.conversionPrice == 0) return (false, "ShareExtension: conversionPrice must be > 0 when convertible"); + if (t.targetConversionSeriesId == bytes32(0)) return (false, "ShareExtension: targetConversionSeriesId must be non-zero when convertible"); + } + + if (t.dividendType == DividendType.None) { + if (t.dividendRate != 0) return (false, "ShareExtension: dividendRate must be 0 when dividendType is None"); + } + + if (t.dividendType == DividendType.Cumulative) { + if (t.dividendAccrualStartDate == 0) return (false, "ShareExtension: dividendAccrualStartDate should be non-zero for Cumulative dividends"); + } + + if (t.liquidationPreferenceType != LiquidationPreferenceType.CappedParticipating) { + if (t.participationCap != 0) return (false, "ShareExtension: participationCap must be 0 when not CappedParticipating"); + } + + if (t.liquidationPreferenceType == LiquidationPreferenceType.CappedParticipating) { + if (t.participationCap == 0) return (false, "ShareExtension: participationCap must be > 0 when CappedParticipating"); + } + + if (!t.isRedeemable) { + if (t.redemptionPrice != 0) return (false, "ShareExtension: redemptionPrice must be 0 when not redeemable"); + if (t.redemptionType != RedemptionType.None) return (false, "ShareExtension: redemptionType must be None when not redeemable"); + } + + return (true, ""); + } + + function _validateCertificateDataInternal(CertificateData memory d) internal view returns (bool, string memory) { + if (!seriesExists[d.seriesId]) return (false, "ShareExtension: seriesId does not reference an existing series"); + + if (d.isPartlyPaid) { + if (d.totalConsideration == 0) return (false, "ShareExtension: totalConsideration must be > 0 when partly paid"); + if (d.amountPaid >= d.totalConsideration) return (false, "ShareExtension: amountPaid must be < totalConsideration when partly paid"); + } + + if (!d.isPartlyPaid) { + if (d.amountPaid != 0) return (false, "ShareExtension: amountPaid must be 0 when not partly paid"); + if (d.totalConsideration != 0) return (false, "ShareExtension: totalConsideration must be 0 when not partly paid"); + } + + return (true, ""); + } + + // ══════════════════════════════════════════════════════════════ + // Internal — URI Builder + // ══════════════════════════════════════════════════════════════ - // Build JSON in segments to avoid stack-too-deep - string memory part1 = _buildIdentityAndEconomics(d); - string memory part2 = _buildConversionAndVoting(d); - string memory part3 = _buildRestrictionsAndRedemption(d); - string memory part4 = _buildProtectiveAndAuth(d); + function _buildURI(CertificateData memory cert) internal view returns (string memory) { + SeriesTerms storage terms = _seriesRegistry[cert.seriesId]; + + string memory p1 = _buildIdentity(terms); + string memory p2 = _buildEconomics(terms); + string memory p3 = _buildDividends(terms); + string memory p4 = _buildConversion(terms, cert.seriesId); + string memory p5 = _buildVoting(terms, cert.seriesId); + string memory p6 = _buildRestrictionsRedemption(terms, cert.seriesId); + string memory p7 = _buildCertificate(cert); + string memory p8 = _buildIssuer(); return string(abi.encodePacked( ', "shareDetails": {', - part1, part2, part3, part4, + p1, p2, p3, p4, p5, p6, p7, p8, "}" )); } - // ────────────────────────────────────────────────────────────── - // Internal JSON-building helpers (split to avoid stack-too-deep) - // ────────────────────────────────────────────────────────────── + function _buildIdentity(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"shareClassKey": "', _shareClassKeyToString(t.shareClassKey), + '", "seriesName": "', t.seriesName, + '", "parValue": "', _uint256ToString(t.parValue), + '", "authorizedShares": "', _uint256ToString(t.authorizedShares), + '", "originalIssuePrice": "', _uint256ToString(t.originalIssuePrice), + '", "effectiveDate": "', _uint256ToString(t.effectiveDate), + '", ' + )); + } - function _buildIdentityAndEconomics(ShareData memory d) internal pure returns (string memory) { + function _buildEconomics(SeriesTerms storage t) internal view returns (string memory) { + if (t.liquidationPreferenceMultiple == 0) { + return string(abi.encodePacked( + '"liquidationPreferenceMultiple": "0", ' + )); + } return string(abi.encodePacked( - '"shareClass": "', _shareClassToString(d.shareClass), - '", "seriesName": "', d.seriesName, - '", "parValue": "', _uint256ToString(d.parValue), - '", "originalIssuePrice": "', _uint256ToString(d.originalIssuePrice), - '", "liquidationPreferenceMultiple": "', _uint256ToString(d.liquidationPreferenceMultiple), - '", "liquidationPreferenceType": "', _liquidationPrefTypeToString(d.liquidationPreferenceType), - '", "participationCap": "', _uint256ToString(d.participationCap), + '"liquidationPreferenceMultiple": "', _uint256ToString(t.liquidationPreferenceMultiple), + '", "liquidationPreferenceType": "', _liquidationPrefTypeToString(t.liquidationPreferenceType), + '", "participationCap": "', _uint256ToString(t.participationCap), + '", "seniorityRank": "', _uint256ToString(t.seniorityRank), '", ' )); } - function _buildConversionAndVoting(ShareData memory d) internal pure returns (string memory) { + function _buildDividends(SeriesTerms storage t) internal view returns (string memory) { + if (t.dividendType == DividendType.None) { + return '"dividendType": "None", '; + } return string(abi.encodePacked( - '"dividendType": "', _dividendTypeToString(d.dividendType), - '", "dividendRateOrPriority": "', _uint256ToString(d.dividendRateOrPriority), - '", "isConvertible": "', _boolToString(d.isConvertible), - '", "conversionPrice": "', _uint256ToString(d.conversionPrice), - '", "antiDilutionType": "', _antiDilutionTypeToString(d.antiDilutionType), - '", "votesPerShare": "', _uint256ToString(d.votesPerShare), - '", "hasClassVotingRights": "', _boolToString(d.hasClassVotingRights), - '", "designatedBoardSeats": "', _uint256ToString(uint256(d.designatedBoardSeats)), + '"dividendType": "', _dividendTypeToString(t.dividendType), + '", "dividendRate": "', _uint256ToString(t.dividendRate), + '", "dividendCompounding": "', _boolToString(t.dividendCompounding), + '", "dividendIncreasesLiquidationAmount": "', _boolToString(t.dividendIncreasesLiquidationAmount), '", ' )); } - function _buildRestrictionsAndRedemption(ShareData memory d) internal pure returns (string memory) { + function _buildConversion(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { + if (!t.isConvertible) { + return '"isConvertible": "false", '; + } return string(abi.encodePacked( - '"transferRestrictionType": "', _transferRestrictionTypeToString(d.transferRestrictionType), - '", "isRedeemable": "', _boolToString(d.isRedeemable), - '", "redemptionPrice": "', _uint256ToString(d.redemptionPrice), + '"isConvertible": "true', + '", "conversionPrice": "', _uint256ToString(t.conversionPrice), + '", "antiDilutionType": "', _antiDilutionTypeToString(t.antiDilutionType), + '", "allowsFractionalConversion": "', _boolToString(t.allowsFractionalConversion), + '", "hasMandatoryConversion": "', _boolToString(t.hasMandatoryConversion), + '", "mandatoryConversionTriggerCount": "', _uint256ToString(_conversionTriggers[seriesId].length), '", ' )); } - function _buildProtectiveAndAuth(ShareData memory d) internal pure returns (string memory) { + function _buildVoting(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { return string(abi.encodePacked( - '"hasProtectiveProvisions": "', _boolToString(d.hasProtectiveProvisions), - '", "protectiveProvisionThreshold": "', _uint256ToString(d.protectiveProvisionThreshold), - '", "authorizedShares": "', _uint256ToString(d.authorizedShares), - '"' + '"votesPerShare": "', _uint256ToString(t.votesPerShare), + '", "designatedBoardSeats": "', _uint256ToString(uint256(t.designatedBoardSeats)), + '", "hasClassVotingRights": "', _boolToString(t.hasClassVotingRights), + '", "hasSeriesVotingRights": "', _boolToString(t.hasSeriesVotingRights), + '", "specialVotingRightsCount": "', _uint256ToString(_specialVotingRights[seriesId].length), + '", ' )); } - // ────────────────────────────────────────────────────────────── - // Enum-to-string helpers - // ────────────────────────────────────────────────────────────── + function _buildRestrictionsRedemption(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { + if (!t.isRedeemable) { + return string(abi.encodePacked( + '"transferRestrictionCount": "', _uint256ToString(_transferRestrictions[seriesId].length), + '", "isRedeemable": "false", ' + )); + } + return string(abi.encodePacked( + '"transferRestrictionCount": "', _uint256ToString(_transferRestrictions[seriesId].length), + '", "isRedeemable": "true', + '", "redemptionType": "', _redemptionTypeToString(t.redemptionType), + '", "redemptionPrice": "', _uint256ToString(t.redemptionPrice), + '", ' + )); + } - function _shareClassToString(ShareClass c) internal pure returns (string memory) { - if (c == ShareClass.Common) return "Common"; - if (c == ShareClass.Preferred) return "Preferred"; - return "Unknown"; + function _buildCertificate(CertificateData memory c) internal pure returns (string memory) { + if (!c.isPartlyPaid) { + return string(abi.encodePacked( + '"certificateNumber": "', _uint256ToString(c.certificateNumber), + '", "numberOfShares": "', _uint256ToString(c.numberOfShares), + '", "issueDate": "', _uint256ToString(c.issueDate), + '", "isPartlyPaid": "false', + '", "representationType": "', _representationTypeToString(c.representationType), + '", ' + )); + } + return string(abi.encodePacked( + '"certificateNumber": "', _uint256ToString(c.certificateNumber), + '", "numberOfShares": "', _uint256ToString(c.numberOfShares), + '", "issueDate": "', _uint256ToString(c.issueDate), + '", "isPartlyPaid": "true', + '", "amountPaid": "', _uint256ToString(c.amountPaid), + '", "totalConsideration": "', _uint256ToString(c.totalConsideration), + '", "representationType": "', _representationTypeToString(c.representationType), + '", ' + )); + } + + function _buildIssuer() internal view returns (string memory) { + return string(abi.encodePacked( + '"issuerName": "', issuerName, + '", "stateOfIncorporation": "', stateOfIncorporation, '"' + )); + } + + // ══════════════════════════════════════════════════════════════ + // Internal — String Helpers + // ══════════════════════════════════════════════════════════════ + + function _shareClassKeyToString(bytes32 key) internal pure returns (string memory) { + if (key == CLASS_COMMON) return "Common"; + if (key == CLASS_PREFERRED) return "Preferred"; + return _bytes32ToHexString(key); + } + + function _bytes32ToHexString(bytes32 value) internal pure returns (string memory) { + bytes memory hexChars = "0123456789abcdef"; + bytes memory result = new bytes(66); // "0x" + 64 hex chars + result[0] = "0"; + result[1] = "x"; + for (uint256 i = 0; i < 32; i++) { + uint8 b = uint8(value[i]); + result[2 + i * 2] = hexChars[b >> 4]; + result[3 + i * 2] = hexChars[b & 0x0f]; + } + return string(result); } function _liquidationPrefTypeToString(LiquidationPreferenceType t) internal pure returns (string memory) { @@ -249,39 +1069,34 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { if (t == TransferRestrictionType.None) return "None"; if (t == TransferRestrictionType.BoardConsentRequired) return "BoardConsentRequired"; if (t == TransferRestrictionType.ROFRAndCoSale) return "ROFRAndCoSale"; - if (t == TransferRestrictionType.Rule144Eligible) return "Rule144Eligible"; + if (t == TransferRestrictionType.LockUp) return "LockUp"; + if (t == TransferRestrictionType.SecuritiesActRestriction) return "SecuritiesActRestriction"; if (t == TransferRestrictionType.CustomRestriction) return "CustomRestriction"; return "Unknown"; } + function _redemptionTypeToString(RedemptionType t) internal pure returns (string memory) { + if (t == RedemptionType.None) return "None"; + if (t == RedemptionType.HolderOptional) return "HolderOptional"; + if (t == RedemptionType.CompanyOptional) return "CompanyOptional"; + if (t == RedemptionType.Mandatory) return "Mandatory"; + if (t == RedemptionType.EventTriggered) return "EventTriggered"; + return "Unknown"; + } + + function _representationTypeToString(ShareRepresentationType t) internal pure returns (string memory) { + if (t == ShareRepresentationType.Certificated) return "Certificated"; + if (t == ShareRepresentationType.Uncertificated) return "Uncertificated"; + if (t == ShareRepresentationType.Tokenized) return "Tokenized"; + return "Unknown"; + } + function _boolToString(bool b) internal pure returns (string memory) { return b ? "true" : "false"; } - // ────────────────────────────────────────────────────────────── - // uint256 → string - // ────────────────────────────────────────────────────────────── - function _uint256ToString(uint256 _i) internal pure returns (string memory) { - if (_i == 0) { - return "0"; - } - uint256 j = _i; - uint256 len; - while (j != 0) { - len++; - j /= 10; - } - bytes memory bstr = new bytes(len); - uint256 k = len; - while (_i != 0) { - k = k - 1; - uint8 temp = uint8(48 + (_i % 10)); - bytes1 b1 = bytes1(temp); - bstr[k] = b1; - _i /= 10; - } - return string(bstr); + return _i.uint256ToString(); } // ────────────────────────────────────────────────────────────── diff --git a/src/storage/extensions/TokenWarrantExtension.sol b/src/storage/extensions/TokenWarrantExtension.sol index e94aadd6..4ab3ea07 100644 --- a/src/storage/extensions/TokenWarrantExtension.sol +++ b/src/storage/extensions/TokenWarrantExtension.sol @@ -45,6 +45,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; +import "../../libs/StringUtils.sol"; struct TokenWarrantData { ExercisePriceMethod exercisePriceMethod; // perToken or perWarrant @@ -62,6 +63,8 @@ struct TokenWarrantData { } contract TokenWarrantExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + using StringUtils for uint256; + bytes32 public constant EXTENSION_TYPE = keccak256("TOKEN_WARRANT"); uint256 public constant PERCENTAGE_PRECISION = 10 ** 4; @@ -91,46 +94,23 @@ contract TokenWarrantExtension is UUPSUpgradeable, ICertificateExtension, BorgAu string memory json = string(abi.encodePacked( ', "warrantDetails": {', '"exercisePriceMethod": "', ExercisePriceMethodToString(decoded.exercisePriceMethod), - '", "exercisePrice": "', uint256ToString(decoded.exercisePrice), + '", "exercisePrice": "', decoded.exercisePrice.uint256ToString(), '", "unlockStartTimeType": "', UnlockStartTimeTypeToString(decoded.unlockStartTimeType), - '", "unlockStartTime": "', uint256ToString(decoded.unlockStartTime), - '", "unlockingPeriod": "', uint256ToString(decoded.unlockingPeriod), - '", "latestExpirationTime": "', uint256ToString(decoded.latestExpirationTime), - '", "unlockingCliffPeriod": "', uint256ToString(decoded.unlockingCliffPeriod), - '", "unlockingCliffPercentage": "', uint256ToString(decoded.unlockingCliffPercentage), + '", "unlockStartTime": "', decoded.unlockStartTime.uint256ToString(), + '", "unlockingPeriod": "', decoded.unlockingPeriod.uint256ToString(), + '", "latestExpirationTime": "', decoded.latestExpirationTime.uint256ToString(), + '", "unlockingCliffPeriod": "', decoded.unlockingCliffPeriod.uint256ToString(), + '", "unlockingCliffPercentage": "', decoded.unlockingCliffPercentage.uint256ToString(), '", "unlockingIntervalType": "', UnlockingIntervalTypeToString(decoded.unlockingIntervalType), '", "tokenCalculationMethod": "', conversionTypeToString(decoded.tokenCalculationMethod), - '", "minCompanyReserve": "', uint256ToString(decoded.minCompanyReserve), - '", "tokenPremiumMultiplier": "', uint256ToString(decoded.tokenPremiumMultiplier), + '", "minCompanyReserve": "', decoded.minCompanyReserve.uint256ToString(), + '", "tokenPremiumMultiplier": "', decoded.tokenPremiumMultiplier.uint256ToString(), '"}' )); return json; } - // Helper function to convert uint256 to string - function uint256ToString(uint256 _i) internal pure returns (string memory) { - if (_i == 0) { - return "0"; - } - uint256 j = _i; - uint256 len; - while (j != 0) { - len++; - j /= 10; - } - bytes memory bstr = new bytes(len); - uint256 k = len; - while (_i != 0) { - k = k-1; - uint8 temp = uint8(48 + (_i % 10)); - bytes1 b1 = bytes1(temp); - bstr[k] = b1; - _i /= 10; - } - return string(bstr); - } - // Helper functions to convert enums to strings function ExercisePriceMethodToString(ExercisePriceMethod _type) internal pure returns (string memory) { if (_type == ExercisePriceMethod.perWarrant) return "perWarrant";