From 12f4454e69e0cdd68f82f3b8a4e0fc118ca4acdf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 13:51:36 +0000 Subject: [PATCH 01/21] Redesign ShareExtension.sol: split series terms from certificate data Implement comprehensive architectural redesign per DGCL compliance and NVCA fidelity requirements: - Split data model into SeriesTerms (canonical, stored once per series) and CertificateData (per-token, referencing series via seriesId) - Add series registry with create/update/targeted-update functions - Add per-certificate legend management (add/remove/initialize) for DGCL section 202 transfer restriction enforceability - Add issuer name as contract-level state (single-write name changes) - Replace closed ShareClass enum with extensible bytes32 shareClassKey - Add liquidation seniority ranking, expanded dividend parameters, conversion target series, mandatory conversion triggers, matter-specific special voting rights, stackable transfer restrictions with full legal text, and expanded redemption types - Add comprehensive validation invariants for series terms and cert data - Add events for series lifecycle, legend changes, and issuer updates - Support V1/V2 backward compatibility in supportsExtensionType and getExtensionURI with automatic format detection - Preserve UUPS storage layout (6 new state vars + 24 gap = 30 slots) https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 756 +++++++++++++++++++--- 1 file changed, 665 insertions(+), 91 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 970d3166..e7eb2907 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; @@ -46,7 +46,12 @@ import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; -/// @notice Classification of capital stock +// ══════════════════════════════════════════════════════════════════════════════ +// Enums (file scope for potential centralization into CyberCorpConstants.sol) +// ══════════════════════════════════════════════════════════════════════════════ + +/// @notice V1 legacy share class enum — retained for backward-compatible decoding of V1 ShareData. +/// V2 uses extensible bytes32 shareClassKey instead. enum ShareClass { Common, Preferred @@ -54,8 +59,8 @@ enum ShareClass { /// @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,104 +76,661 @@ 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. +/// NOTE: Rule144Eligible removed — that is a holder/time condition, not a series designation. +/// SecuritiesActRestriction added — the standard "not registered" restricted-securities legend. 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 +} - // === Economic Rights === - uint256 originalIssuePrice; // price per share at issuance (18 decimals) - uint256 liquidationPreferenceMultiple; // multiple of OIP for preference (18 decimals, 1e18 = 1x) +/// @notice Types of mandatory/automatic conversion triggers +enum MandatoryConversionTriggerType { + QualifiedIPO, + ClassVote, + DeemedLiquidation, + Custom +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Structs +// ══════════════════════════════════════════════════════════════════════════════ + +/// @notice V1 legacy share data struct — retained for backward-compatible decoding. +/// New certificates should use CertificateData (V2) with a SeriesTerms reference. +struct ShareData { + ShareClass shareClass; + string seriesName; + uint256 parValue; + uint256 originalIssuePrice; + uint256 liquidationPreferenceMultiple; LiquidationPreferenceType liquidationPreferenceType; - uint256 participationCap; // if CappedParticipating, max multiple of OIP (18 decimals); 0 otherwise + uint256 participationCap; DividendType dividendType; - uint256 dividendRateOrPriority; // annual rate (18 decimals, e.g. 8% = 8e16) or 0 for pari passu with common - - // === Conversion === - bool isConvertible; // whether shares are convertible to Common - uint256 conversionPrice; // conversion price (18 decimals); conversion ratio = OIP / conversionPrice + uint256 dividendRateOrPriority; + bool isConvertible; + uint256 conversionPrice; AntiDilutionType antiDilutionType; + uint256 votesPerShare; + bool hasClassVotingRights; + uint8 designatedBoardSeats; + TransferRestrictionType transferRestrictionType; + bool isRedeemable; + uint256 redemptionPrice; + bool hasProtectiveProvisions; + uint256 protectiveProvisionThreshold; + uint256 authorizedShares; +} - // === 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 +/// @notice Mandatory/automatic conversion trigger definition +struct MandatoryConversionTrigger { + MandatoryConversionTriggerType triggerType; + uint256 thresholdValue; // e.g., IPO price threshold (18 dec), vote percentage (4 dec) + string description; // human-readable description of the trigger condition +} - // === Transfer Restrictions === - TransferRestrictionType transferRestrictionType; +/// @notice Matter-specific voting right (used for protective provisions and special class votes) +struct SpecialVotingRight { + bytes32 matterType; // e.g., keccak256("CHARTER_AMENDMENT"), keccak256("MERGER_APPROVAL") + 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 + string description; // human-readable description +} + +/// @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) +} + +/// @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%). +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 effectiveDate; // timestamp when these terms became effective + string sourceAuthorityURI; // pointer to charter provision, board resolution, or amendment - // === Redemption === - bool isRedeemable; // whether shares are subject to optional or mandatory redemption - uint256 redemptionPrice; // redemption price per share (18 decimals); 0 if not redeemable + // --- 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 - // === 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%) + // --- Dividends --- + DividendType dividendType; + 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; + 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; + MandatoryConversionTrigger[] mandatoryConversionTriggers; + + // --- 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 votes as a separate class on certain matters + SpecialVotingRight[] specialVotingRights; + + // --- Transfer Restrictions --- + TransferRestriction[] transferRestrictions; + + // --- 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 +} - // === Authorization === - uint256 authorizedShares; // total authorized shares of this class/series +/// @notice Per-certificate metadata (V2). 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. } +// ══════════════════════════════════════════════════════════════════════════════ +// Contract +// ══════════════════════════════════════════════════════════════════════════════ + contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + + // ────────────────────────────────────────────────────────────── + // Constants + // ────────────────────────────────────────────────────────────── + bytes32 public constant EXTENSION_TYPE = keccak256("SHARE"); + bytes32 public constant EXTENSION_TYPE_V2 = keccak256("SHARE_V2"); 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"); + + // ────────────────────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────────────────────── + + event SeriesCreated(bytes32 indexed seriesId, bytes32 indexed shareClassKey, string seriesName); + event SeriesTermsUpdated(bytes32 indexed seriesId, string fieldChanged); + event ConversionPriceAdjusted(bytes32 indexed seriesId, uint256 oldPrice, uint256 newPrice); + event AuthorizedSharesChanged(bytes32 indexed seriesId, uint256 oldAmount, uint256 newAmount); + event LegendAdded(uint256 indexed tokenId, uint256 legendIndex, bytes32 legendHash); + event LegendRemoved(uint256 indexed tokenId, uint256 legendIndex, bytes32 legendHash); + event IssuerNameUpdated(string oldName, string newName); + + // ────────────────────────────────────────────────────────────── + // State variables + // NOTE: These occupy the same storage slots as the former __gap[30]. + // 6 slots used + 24 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 Per-certificate legend texts keyed by token ID + mapping(uint256 => string[]) internal _certificateLegends; // slot 3 + /// @notice Parallel array of legend hashes for efficient on-chain verification + mapping(uint256 => bytes32[]) internal _certificateLegendHashes; // slot 4 + /// @notice Issuer name — changeable by board/owner (covers name changes, mergers) + string public issuerName; // slot 5 + + /// @dev Reserved storage for future upgrades + uint256[24] private __gap; // slots 6-29 + + // ────────────────────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────────────────────── function initialize(address _auth) external initializer { __UUPSUpgradeable_init(); __BorgAuthACL_init(_auth); } + // ══════════════════════════════════════════════════════════════ + // Series Management (onlyOwner / board-authorized) + // ══════════════════════════════════════════════════════════════ + + /// @notice Register a new series with canonical terms + /// @param seriesId Unique identifier for the series (immutable once created) + /// @param terms The full series terms struct + 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; + + emit SeriesCreated(seriesId, terms.shareClassKey, terms.seriesName); + } + + /// @notice Replace the full terms for an existing series + /// @param seriesId The series to update + /// @param terms The new full series terms + 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); + + _seriesRegistry[seriesId] = terms; + + emit SeriesTermsUpdated(seriesId, "ALL"); + } + + /// @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"); + + 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; + emit SeriesTermsUpdated(seriesId, "seriesName"); + } + + // ══════════════════════════════════════════════════════════════ + // Certificate Data (V2 — ICertificateExtension compatibility) + // ══════════════════════════════════════════════════════════════ + + /// @notice Encode V2 certificate data into extension bytes + function encodeCertificateData(CertificateData memory data) external pure returns (bytes memory) { + return abi.encode(data); + } + + /// @notice Decode V2 certificate data from extension bytes + function decodeCertificateData(bytes memory data) external pure returns (CertificateData memory) { + return abi.decode(data, (CertificateData)); + } + + // ══════════════════════════════════════════════════════════════ + // V1 Legacy Encode/Decode (backward compatibility) + // ══════════════════════════════════════════════════════════════ + + /// @notice Decode V1 ShareData from extension bytes (legacy) function decodeExtensionData(bytes memory data) external pure returns (ShareData memory) { return abi.decode(data, (ShareData)); } + /// @notice Encode V1 ShareData into extension bytes (legacy) function encodeExtensionData(ShareData memory data) external pure returns (bytes memory) { return abi.encode(data); } + // ══════════════════════════════════════════════════════════════ + // Legend Management (per-certificate) + // ══════════════════════════════════════════════════════════════ + + /// @notice Add a legend to a specific certificate + /// @param tokenId The ERC-721 token ID + /// @param legendText The full legal legend/restriction notice text + function addLegend(uint256 tokenId, string calldata legendText) external onlyOwner { + bytes32 h = keccak256(bytes(legendText)); + _certificateLegends[tokenId].push(legendText); + _certificateLegendHashes[tokenId].push(h); + + emit LegendAdded(tokenId, _certificateLegends[tokenId].length - 1, h); + } + + /// @notice Remove a legend from a specific certificate by index (swap-and-pop) + /// @param tokenId The ERC-721 token ID + /// @param legendIndex Index of the legend to remove + function removeLegend(uint256 tokenId, uint256 legendIndex) external onlyOwner { + string[] storage legends = _certificateLegends[tokenId]; + bytes32[] storage hashes = _certificateLegendHashes[tokenId]; + require(legendIndex < legends.length, "ShareExtension: legend index out of bounds"); + + bytes32 removedHash = hashes[legendIndex]; + + // Swap with last element and pop + uint256 lastIdx = legends.length - 1; + if (legendIndex != lastIdx) { + legends[legendIndex] = legends[lastIdx]; + hashes[legendIndex] = hashes[lastIdx]; + } + legends.pop(); + hashes.pop(); + + emit LegendRemoved(tokenId, legendIndex, removedHash); + } + + /// @notice Get all legends for a certificate + function getLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes) { + return (_certificateLegends[tokenId], _certificateLegendHashes[tokenId]); + } + + /// @notice Initialize a certificate's legends from its series' default transfer restrictions + /// @dev Should be called at mint time to populate initial restriction notices + function initializeLegends(uint256 tokenId, bytes32 seriesId) external onlyOwner { + require(seriesExists[seriesId], "ShareExtension: series does not exist"); + + TransferRestriction[] storage restrictions = _seriesRegistry[seriesId].transferRestrictions; + for (uint256 i = 0; i < restrictions.length; i++) { + if (bytes(restrictions[i].restrictionText).length > 0) { + bytes32 h = keccak256(bytes(restrictions[i].restrictionText)); + _certificateLegends[tokenId].push(restrictions[i].restrictionText); + _certificateLegendHashes[tokenId].push(h); + emit LegendAdded(tokenId, _certificateLegends[tokenId].length - 1, h); + } + } + } + + // ══════════════════════════════════════════════════════════════ + // Issuer Identity + // ══════════════════════════════════════════════════════════════ + + /// @notice Set or update the issuer name (single authoritative value for all certs) + function setIssuerName(string calldata _name) external onlyOwner { + string memory oldName = issuerName; + issuerName = _name; + emit IssuerNameUpdated(oldName, _name); + } + + // ══════════════════════════════════════════════════════════════ + // 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 full share info: series terms + decoded certificate data + legends + /// @param certExtensionData The encoded V2 CertificateData bytes + /// @param tokenId The ERC-721 token ID (for legend lookup) + 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]; + legends = _certificateLegends[tokenId]; + } + + // ══════════════════════════════════════════════════════════════ + // Validation + // ══════════════════════════════════════════════════════════════ + + /// @notice Validate series terms without modifying state + /// @dev `view` because it may check seriesExists for targetConversionSeriesId + 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 + // ══════════════════════════════════════════════════════════════ + + /// @notice Supports both V1 ("SHARE") and V2 ("SHARE_V2") extension types function supportsExtensionType(bytes32 extensionType) external pure override returns (bool) { - return extensionType == EXTENSION_TYPE; + return extensionType == EXTENSION_TYPE || extensionType == EXTENSION_TYPE_V2; + } + + /// @notice Render extension data as a JSON fragment. + /// @dev Now `view` (not `pure`) because it reads seriesRegistry, legends, and issuerName. + /// Detects V1 vs V2 format: if the first 32 bytes match a known seriesId, decodes as V2. + function getExtensionURI(bytes memory data) external view override returns (string memory) { + if (data.length == 0) return ""; + + // Attempt V2 detection: first 32 bytes of CertificateData is the seriesId + bytes32 potentialSeriesId; + // solhint-disable-next-line no-inline-assembly + assembly { + potentialSeriesId := mload(add(data, 32)) + } + + if (seriesExists[potentialSeriesId]) { + return _buildV2URI(data, potentialSeriesId); + } + + // Fall back to V1 decoding + return _buildV1URI(data); + } + + // ══════════════════════════════════════════════════════════════ + // Internal — Validation + // ══════════════════════════════════════════════════════════════ + + function _validateSeriesTermsInternal(SeriesTerms memory t) internal view returns (bool, string memory) { + // 9. authorizedShares must be > 0 + if (t.authorizedShares == 0) return (false, "ShareExtension: authorizedShares must be > 0"); + + // 10. parValue should be > 0 + if (t.parValue == 0) return (false, "ShareExtension: parValue must be > 0"); + + // 1. If not convertible, conversion fields must be zero/empty + 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.mandatoryConversionTriggers.length > 0) return (false, "ShareExtension: mandatoryConversionTriggers must be empty when not convertible"); + if (t.hasMandatoryConversion) return (false, "ShareExtension: hasMandatoryConversion must be false when not convertible"); + } + + // 2. If convertible, targetConversionSeriesId must be non-zero + if (t.isConvertible) { + if (t.targetConversionSeriesId == bytes32(0)) return (false, "ShareExtension: targetConversionSeriesId must be non-zero when convertible"); + } + + // 3. If dividendType == None, dividendRate must be 0 + if (t.dividendType == DividendType.None) { + if (t.dividendRate != 0) return (false, "ShareExtension: dividendRate must be 0 when dividendType is None"); + } + + // 4. If dividendType == Cumulative, accrualStartDate should be non-zero + if (t.dividendType == DividendType.Cumulative) { + if (t.dividendAccrualStartDate == 0) return (false, "ShareExtension: dividendAccrualStartDate should be non-zero for Cumulative dividends"); + } + + // 5. If not CappedParticipating, participationCap must be 0 + if (t.liquidationPreferenceType != LiquidationPreferenceType.CappedParticipating) { + if (t.participationCap != 0) return (false, "ShareExtension: participationCap must be 0 when not CappedParticipating"); + } + + // 6. If CappedParticipating, participationCap must be > 0 + if (t.liquidationPreferenceType == LiquidationPreferenceType.CappedParticipating) { + if (t.participationCap == 0) return (false, "ShareExtension: participationCap must be > 0 when CappedParticipating"); + } + + // 7. If not redeemable, redemptionPrice must be 0 and redemptionType must be None + 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"); + } + + // 11. hasMandatoryConversion requires at least one trigger + if (t.hasMandatoryConversion) { + if (t.mandatoryConversionTriggers.length == 0) return (false, "ShareExtension: hasMandatoryConversion requires at least one trigger"); + } + + return (true, ""); + } + + function _validateCertificateDataInternal(CertificateData memory d) internal view returns (bool, string memory) { + // 13. seriesId must reference an existing series + if (!seriesExists[d.seriesId]) return (false, "ShareExtension: seriesId does not reference an existing series"); + + // 14. If partly paid, amountPaid < totalConsideration and totalConsideration > 0 + 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"); + } + + // 15. If not partly paid, amountPaid and totalConsideration should both be 0 + 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 — V2 URI Builder + // ══════════════════════════════════════════════════════════════ + + function _buildV2URI(bytes memory data, bytes32 sid) internal view returns (string memory) { + CertificateData memory cert = abi.decode(data, (CertificateData)); + SeriesTerms storage terms = _seriesRegistry[sid]; + + string memory p1 = _buildV2Identity(terms); + string memory p2 = _buildV2Economics(terms); + string memory p3 = _buildV2Dividends(terms); + string memory p4 = _buildV2Conversion(terms); + string memory p5 = _buildV2Voting(terms); + string memory p6 = _buildV2RestrictionsRedemption(terms); + string memory p7 = _buildV2Certificate(cert); + string memory p8 = _buildV2Issuer(); + + return string(abi.encodePacked( + ', "shareDetails": {', + '"version": "2", ', + p1, p2, p3, p4, p5, p6, p7, p8, + "}" + )); + } + + function _buildV2Identity(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 getExtensionURI(bytes memory data) external pure override returns (string memory) { + function _buildV2Economics(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"liquidationPreferenceMultiple": "', _uint256ToString(t.liquidationPreferenceMultiple), + '", "liquidationPreferenceType": "', _liquidationPrefTypeToString(t.liquidationPreferenceType), + '", "participationCap": "', _uint256ToString(t.participationCap), + '", "seniorityRank": "', _uint256ToString(t.seniorityRank), + '", ' + )); + } + + function _buildV2Dividends(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"dividendType": "', _dividendTypeToString(t.dividendType), + '", "dividendRate": "', _uint256ToString(t.dividendRate), + '", "dividendCompounding": "', _boolToString(t.dividendCompounding), + '", "dividendIncreasesLiquidationAmount": "', _boolToString(t.dividendIncreasesLiquidationAmount), + '", ' + )); + } + + function _buildV2Conversion(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"isConvertible": "', _boolToString(t.isConvertible), + '", "conversionPrice": "', _uint256ToString(t.conversionPrice), + '", "antiDilutionType": "', _antiDilutionTypeToString(t.antiDilutionType), + '", "allowsFractionalConversion": "', _boolToString(t.allowsFractionalConversion), + '", "hasMandatoryConversion": "', _boolToString(t.hasMandatoryConversion), + '", "mandatoryConversionTriggerCount": "', _uint256ToString(t.mandatoryConversionTriggers.length), + '", ' + )); + } + + function _buildV2Voting(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"votesPerShare": "', _uint256ToString(t.votesPerShare), + '", "designatedBoardSeats": "', _uint256ToString(uint256(t.designatedBoardSeats)), + '", "hasClassVotingRights": "', _boolToString(t.hasClassVotingRights), + '", "specialVotingRightsCount": "', _uint256ToString(t.specialVotingRights.length), + '", ' + )); + } + + function _buildV2RestrictionsRedemption(SeriesTerms storage t) internal view returns (string memory) { + return string(abi.encodePacked( + '"transferRestrictionCount": "', _uint256ToString(t.transferRestrictions.length), + '", "isRedeemable": "', _boolToString(t.isRedeemable), + '", "redemptionType": "', _redemptionTypeToString(t.redemptionType), + '", "redemptionPrice": "', _uint256ToString(t.redemptionPrice), + '", ' + )); + } + + function _buildV2Certificate(CertificateData memory c) internal pure returns (string memory) { + return string(abi.encodePacked( + '"certificateNumber": "', _uint256ToString(c.certificateNumber), + '", "numberOfShares": "', _uint256ToString(c.numberOfShares), + '", "issueDate": "', _uint256ToString(c.issueDate), + '", "isPartlyPaid": "', _boolToString(c.isPartlyPaid), + '", "amountPaid": "', _uint256ToString(c.amountPaid), + '", "totalConsideration": "', _uint256ToString(c.totalConsideration), + '", ' + )); + } + + function _buildV2Issuer() internal view returns (string memory) { + return string(abi.encodePacked( + '"issuerName": "', issuerName, '"' + )); + } + + // ══════════════════════════════════════════════════════════════ + // Internal — V1 URI Builder (backward compatibility) + // ══════════════════════════════════════════════════════════════ + + function _buildV1URI(bytes memory data) internal pure returns (string memory) { ShareData memory d = abi.decode(data, (ShareData)); - // 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); + string memory part1 = _buildV1IdentityAndEconomics(d); + string memory part2 = _buildV1ConversionAndVoting(d); + string memory part3 = _buildV1RestrictionsAndRedemption(d); + string memory part4 = _buildV1ProtectiveAndAuth(d); return string(abi.encodePacked( ', "shareDetails": {', + '"version": "1", ', part1, part2, part3, part4, "}" )); } - // ────────────────────────────────────────────────────────────── - // Internal JSON-building helpers (split to avoid stack-too-deep) - // ────────────────────────────────────────────────────────────── - - function _buildIdentityAndEconomics(ShareData memory d) internal pure returns (string memory) { + function _buildV1IdentityAndEconomics(ShareData memory d) internal pure returns (string memory) { return string(abi.encodePacked( '"shareClass": "', _shareClassToString(d.shareClass), '", "seriesName": "', d.seriesName, @@ -181,7 +743,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildConversionAndVoting(ShareData memory d) internal pure returns (string memory) { + function _buildV1ConversionAndVoting(ShareData memory d) internal pure returns (string memory) { return string(abi.encodePacked( '"dividendType": "', _dividendTypeToString(d.dividendType), '", "dividendRateOrPriority": "', _uint256ToString(d.dividendRateOrPriority), @@ -195,7 +757,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildRestrictionsAndRedemption(ShareData memory d) internal pure returns (string memory) { + function _buildV1RestrictionsAndRedemption(ShareData memory d) internal pure returns (string memory) { return string(abi.encodePacked( '"transferRestrictionType": "', _transferRestrictionTypeToString(d.transferRestrictionType), '", "isRedeemable": "', _boolToString(d.isRedeemable), @@ -204,7 +766,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildProtectiveAndAuth(ShareData memory d) internal pure returns (string memory) { + function _buildV1ProtectiveAndAuth(ShareData memory d) internal pure returns (string memory) { return string(abi.encodePacked( '"hasProtectiveProvisions": "', _boolToString(d.hasProtectiveProvisions), '", "protectiveProvisionThreshold": "', _uint256ToString(d.protectiveProvisionThreshold), @@ -213,9 +775,15 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - // ────────────────────────────────────────────────────────────── - // Enum-to-string helpers - // ────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════ + // 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 "Custom"; + } function _shareClassToString(ShareClass c) internal pure returns (string memory) { if (c == ShareClass.Common) return "Common"; @@ -249,19 +817,25 @@ 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 _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"; From b35b5fa0a2df3dacdcac9f8a55985f3d38f90316 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 13:52:18 +0000 Subject: [PATCH 02/21] Add foundry.lock from dependency installation https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- foundry.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 foundry.lock 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 From 803f8a9968231f80478084a7c605afc607df4187 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:03:29 +0000 Subject: [PATCH 03/21] Distinguish class-wide vs series-specific voting rights Add VotingScope enum (ClassWide, SeriesSpecific) to differentiate DGCL section 151(a) class-level votes (all Preferred voting together) from series-level votes (e.g., Series A voting alone). Add scope field to SpecialVotingRight struct, add hasSeriesVotingRights bool to SeriesTerms alongside existing hasClassVotingRights. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index e7eb2907..675975af 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -108,6 +108,16 @@ enum MandatoryConversionTriggerType { 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. These are distinct: +/// - ClassWide: all series within the same share class vote together (e.g., all Preferred) +/// - SeriesSpecific: only holders of this specific series vote (e.g., Series A alone) +enum VotingScope { + ClassWide, // all series sharing the same shareClassKey vote together + SeriesSpecific // only this series votes +} + // ══════════════════════════════════════════════════════════════════════════════ // Structs // ══════════════════════════════════════════════════════════════════════════════ @@ -145,12 +155,13 @@ struct MandatoryConversionTrigger { string description; // human-readable description of the trigger condition } -/// @notice Matter-specific voting right (used for protective provisions and special class votes) +/// @notice Matter-specific voting right (used for protective provisions and special class/series votes) struct SpecialVotingRight { bytes32 matterType; // e.g., keccak256("CHARTER_AMENDMENT"), keccak256("MERGER_APPROVAL") 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 } @@ -200,8 +211,11 @@ struct SeriesTerms { // --- 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 votes as a separate class on certain matters - SpecialVotingRight[] specialVotingRights; + bool hasClassVotingRights; // whether this series participates in class-wide separate votes + // (e.g., all Preferred series voting together as a single class) + bool hasSeriesVotingRights; // whether this series can vote separately as its own series + // (e.g., Series A alone voting on matters requiring Series A consent) + SpecialVotingRight[] specialVotingRights; // each entry specifies its own VotingScope // --- Transfer Restrictions --- TransferRestriction[] transferRestrictions; @@ -677,6 +691,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { '"votesPerShare": "', _uint256ToString(t.votesPerShare), '", "designatedBoardSeats": "', _uint256ToString(uint256(t.designatedBoardSeats)), '", "hasClassVotingRights": "', _boolToString(t.hasClassVotingRights), + '", "hasSeriesVotingRights": "', _boolToString(t.hasSeriesVotingRights), '", "specialVotingRightsCount": "', _uint256ToString(t.specialVotingRights.length), '", ' )); From 04e64590a4a76e9533f4610d8e17944179c43774 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:08:01 +0000 Subject: [PATCH 04/21] Fix V1 backward compat: preserve TransferRestrictionType enum ordinals Restore Rule144Eligible at ordinal 3 and CustomRestriction at ordinal 4 to match the original V1 ABI encoding. Previously issued V1 certificates would have decoded to wrong restriction types after the ordinal shift. New types (LockUp, SecuritiesActRestriction) appended at ordinals 5-6. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 675975af..4c9b28b1 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -80,15 +80,19 @@ enum DividendType { } /// @notice Transfer restriction regime applicable to shares. -/// NOTE: Rule144Eligible removed — that is a holder/time condition, not a series designation. -/// SecuritiesActRestriction added — the standard "not registered" restricted-securities legend. +/// IMPORTANT: Ordinals 0-4 are frozen to preserve V1 ABI backward compatibility. +/// Rule144Eligible (ordinal 3) is deprecated — it is a holder/time condition, not a series +/// designation — but retained at its original position so V1-encoded certificates decode correctly. +/// New restriction types are appended after the original ordinals. enum TransferRestrictionType { - None, - BoardConsentRequired, // Section 8.9(a) of Bylaws — no transfer without board consent - ROFRAndCoSale, // subject to ROFR/Co-Sale agreement - LockUp, // time-based lock-up restriction - SecuritiesActRestriction, // Securities Act restricted legend (standard "not registered" legend) - CustomRestriction // custom restriction defined by agreement or board resolution + None, // 0 + BoardConsentRequired, // 1 — Section 8.9(a) of Bylaws + ROFRAndCoSale, // 2 — subject to ROFR/Co-Sale agreement + Rule144Eligible, // 3 — DEPRECATED (V1 compat only); do not use for new series + CustomRestriction, // 4 — custom restriction defined by agreement or board resolution + // --- V2 additions (ordinal 5+) --- + LockUp, // 5 — time-based lock-up restriction + SecuritiesActRestriction // 6 — Securities Act restricted legend (standard "not registered" legend) } /// @notice Redemption mechanism type @@ -832,9 +836,10 @@ 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.CustomRestriction) return "CustomRestriction"; if (t == TransferRestrictionType.LockUp) return "LockUp"; if (t == TransferRestrictionType.SecuritiesActRestriction) return "SecuritiesActRestriction"; - if (t == TransferRestrictionType.CustomRestriction) return "CustomRestriction"; return "Unknown"; } From cdf814555ddc1ee28c3a95714d453a037f7f71dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:11:13 +0000 Subject: [PATCH 05/21] =?UTF-8?q?Revert=20V1=20enum=20ordinal=20preservati?= =?UTF-8?q?on=20=E2=80=94=20clean=20up=20TransferRestrictionType?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No V1 certificates exist in production, so ordinal preservation is unnecessary. Restore the clean enum ordering without the deprecated Rule144Eligible placeholder. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 4c9b28b1..675975af 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -80,19 +80,15 @@ enum DividendType { } /// @notice Transfer restriction regime applicable to shares. -/// IMPORTANT: Ordinals 0-4 are frozen to preserve V1 ABI backward compatibility. -/// Rule144Eligible (ordinal 3) is deprecated — it is a holder/time condition, not a series -/// designation — but retained at its original position so V1-encoded certificates decode correctly. -/// New restriction types are appended after the original ordinals. +/// NOTE: Rule144Eligible removed — that is a holder/time condition, not a series designation. +/// SecuritiesActRestriction added — the standard "not registered" restricted-securities legend. enum TransferRestrictionType { - None, // 0 - BoardConsentRequired, // 1 — Section 8.9(a) of Bylaws - ROFRAndCoSale, // 2 — subject to ROFR/Co-Sale agreement - Rule144Eligible, // 3 — DEPRECATED (V1 compat only); do not use for new series - CustomRestriction, // 4 — custom restriction defined by agreement or board resolution - // --- V2 additions (ordinal 5+) --- - LockUp, // 5 — time-based lock-up restriction - SecuritiesActRestriction // 6 — Securities Act restricted legend (standard "not registered" legend) + None, + BoardConsentRequired, // Section 8.9(a) of Bylaws — no transfer without board consent + ROFRAndCoSale, // subject to ROFR/Co-Sale agreement + 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 Redemption mechanism type @@ -836,10 +832,9 @@ 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.CustomRestriction) return "CustomRestriction"; if (t == TransferRestrictionType.LockUp) return "LockUp"; if (t == TransferRestrictionType.SecuritiesActRestriction) return "SecuritiesActRestriction"; + if (t == TransferRestrictionType.CustomRestriction) return "CustomRestriction"; return "Unknown"; } From 31b6cea66a0110fc40a84a6b5388379270729427 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:17:39 +0000 Subject: [PATCH 06/21] =?UTF-8?q?Remove=20V1=20backward=20compatibility=20?= =?UTF-8?q?layer=20=E2=80=94=20no=20prior=20deployment=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ShareClass enum, ShareData struct, V1 encode/decode functions, V1 URI builder, V1/V2 detection logic, and EXTENSION_TYPE_V2 constant. Simplify getExtensionURI to decode CertificateData directly. Rename internal _buildV2* helpers to _build* since there is only one version. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 188 ++++------------------ 1 file changed, 28 insertions(+), 160 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 675975af..e7084a55 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -50,13 +50,6 @@ import "../../libs/auth.sol"; // Enums (file scope for potential centralization into CyberCorpConstants.sol) // ══════════════════════════════════════════════════════════════════════════════ -/// @notice V1 legacy share class enum — retained for backward-compatible decoding of V1 ShareData. -/// V2 uses extensible bytes32 shareClassKey instead. -enum ShareClass { - Common, - Preferred -} - /// @notice Liquidation preference payout structure enum LiquidationPreferenceType { NonParticipating, // single-dip: greater of preference or as-converted @@ -122,32 +115,6 @@ enum VotingScope { // Structs // ══════════════════════════════════════════════════════════════════════════════ -/// @notice V1 legacy share data struct — retained for backward-compatible decoding. -/// New certificates should use CertificateData (V2) with a SeriesTerms reference. -struct ShareData { - ShareClass shareClass; - string seriesName; - uint256 parValue; - uint256 originalIssuePrice; - uint256 liquidationPreferenceMultiple; - LiquidationPreferenceType liquidationPreferenceType; - uint256 participationCap; - DividendType dividendType; - uint256 dividendRateOrPriority; - bool isConvertible; - uint256 conversionPrice; - AntiDilutionType antiDilutionType; - uint256 votesPerShare; - bool hasClassVotingRights; - uint8 designatedBoardSeats; - TransferRestrictionType transferRestrictionType; - bool isRedeemable; - uint256 redemptionPrice; - bool hasProtectiveProvisions; - uint256 protectiveProvisionThreshold; - uint256 authorizedShares; -} - /// @notice Mandatory/automatic conversion trigger definition struct MandatoryConversionTrigger { MandatoryConversionTriggerType triggerType; @@ -252,7 +219,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // ────────────────────────────────────────────────────────────── bytes32 public constant EXTENSION_TYPE = keccak256("SHARE"); - bytes32 public constant EXTENSION_TYPE_V2 = keccak256("SHARE_V2"); uint256 public constant PERCENTAGE_PRECISION = 10 ** 4; uint256 public constant PRICE_PRECISION = 10 ** 18; @@ -370,33 +336,19 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } // ══════════════════════════════════════════════════════════════ - // Certificate Data (V2 — ICertificateExtension compatibility) + // Certificate Data (ICertificateExtension compatibility) // ══════════════════════════════════════════════════════════════ - /// @notice Encode V2 certificate data into extension bytes + /// @notice Encode certificate data into extension bytes function encodeCertificateData(CertificateData memory data) external pure returns (bytes memory) { return abi.encode(data); } - /// @notice Decode V2 certificate data from extension bytes + /// @notice Decode certificate data from extension bytes function decodeCertificateData(bytes memory data) external pure returns (CertificateData memory) { return abi.decode(data, (CertificateData)); } - // ══════════════════════════════════════════════════════════════ - // V1 Legacy Encode/Decode (backward compatibility) - // ══════════════════════════════════════════════════════════════ - - /// @notice Decode V1 ShareData from extension bytes (legacy) - function decodeExtensionData(bytes memory data) external pure returns (ShareData memory) { - return abi.decode(data, (ShareData)); - } - - /// @notice Encode V1 ShareData into extension bytes (legacy) - function encodeExtensionData(ShareData memory data) external pure returns (bytes memory) { - return abi.encode(data); - } - // ══════════════════════════════════════════════════════════════ // Legend Management (per-certificate) // ══════════════════════════════════════════════════════════════ @@ -482,7 +434,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } /// @notice Get full share info: series terms + decoded certificate data + legends - /// @param certExtensionData The encoded V2 CertificateData bytes + /// @param certExtensionData The encoded CertificateData bytes /// @param tokenId The ERC-721 token ID (for legend lookup) function getFullShareInfo(bytes memory certExtensionData, uint256 tokenId) external @@ -514,30 +466,19 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // ICertificateExtension Overrides // ══════════════════════════════════════════════════════════════ - /// @notice Supports both V1 ("SHARE") and V2 ("SHARE_V2") extension types function supportsExtensionType(bytes32 extensionType) external pure override returns (bool) { - return extensionType == EXTENSION_TYPE || extensionType == EXTENSION_TYPE_V2; + return extensionType == EXTENSION_TYPE; } /// @notice Render extension data as a JSON fragment. - /// @dev Now `view` (not `pure`) because it reads seriesRegistry, legends, and issuerName. - /// Detects V1 vs V2 format: if the first 32 bytes match a known seriesId, decodes as V2. + /// @dev `view` (not `pure`) because it reads seriesRegistry and issuerName. function getExtensionURI(bytes memory data) external view override returns (string memory) { if (data.length == 0) return ""; - // Attempt V2 detection: first 32 bytes of CertificateData is the seriesId - bytes32 potentialSeriesId; - // solhint-disable-next-line no-inline-assembly - assembly { - potentialSeriesId := mload(add(data, 32)) - } - - if (seriesExists[potentialSeriesId]) { - return _buildV2URI(data, potentialSeriesId); - } + CertificateData memory cert = abi.decode(data, (CertificateData)); + require(seriesExists[cert.seriesId], "ShareExtension: unknown seriesId in extension data"); - // Fall back to V1 decoding - return _buildV1URI(data); + return _buildURI(cert); } // ══════════════════════════════════════════════════════════════ @@ -618,31 +559,29 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } // ══════════════════════════════════════════════════════════════ - // Internal — V2 URI Builder + // Internal — URI Builder // ══════════════════════════════════════════════════════════════ - function _buildV2URI(bytes memory data, bytes32 sid) internal view returns (string memory) { - CertificateData memory cert = abi.decode(data, (CertificateData)); - SeriesTerms storage terms = _seriesRegistry[sid]; + function _buildURI(CertificateData memory cert) internal view returns (string memory) { + SeriesTerms storage terms = _seriesRegistry[cert.seriesId]; - string memory p1 = _buildV2Identity(terms); - string memory p2 = _buildV2Economics(terms); - string memory p3 = _buildV2Dividends(terms); - string memory p4 = _buildV2Conversion(terms); - string memory p5 = _buildV2Voting(terms); - string memory p6 = _buildV2RestrictionsRedemption(terms); - string memory p7 = _buildV2Certificate(cert); - string memory p8 = _buildV2Issuer(); + string memory p1 = _buildIdentity(terms); + string memory p2 = _buildEconomics(terms); + string memory p3 = _buildDividends(terms); + string memory p4 = _buildConversion(terms); + string memory p5 = _buildVoting(terms); + string memory p6 = _buildRestrictionsRedemption(terms); + string memory p7 = _buildCertificate(cert); + string memory p8 = _buildIssuer(); return string(abi.encodePacked( ', "shareDetails": {', - '"version": "2", ', p1, p2, p3, p4, p5, p6, p7, p8, "}" )); } - function _buildV2Identity(SeriesTerms storage t) internal view returns (string memory) { + function _buildIdentity(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"shareClassKey": "', _shareClassKeyToString(t.shareClassKey), '", "seriesName": "', t.seriesName, @@ -654,7 +593,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Economics(SeriesTerms storage t) internal view returns (string memory) { + function _buildEconomics(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"liquidationPreferenceMultiple": "', _uint256ToString(t.liquidationPreferenceMultiple), '", "liquidationPreferenceType": "', _liquidationPrefTypeToString(t.liquidationPreferenceType), @@ -664,7 +603,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Dividends(SeriesTerms storage t) internal view returns (string memory) { + function _buildDividends(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"dividendType": "', _dividendTypeToString(t.dividendType), '", "dividendRate": "', _uint256ToString(t.dividendRate), @@ -674,7 +613,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Conversion(SeriesTerms storage t) internal view returns (string memory) { + function _buildConversion(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"isConvertible": "', _boolToString(t.isConvertible), '", "conversionPrice": "', _uint256ToString(t.conversionPrice), @@ -686,7 +625,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Voting(SeriesTerms storage t) internal view returns (string memory) { + function _buildVoting(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"votesPerShare": "', _uint256ToString(t.votesPerShare), '", "designatedBoardSeats": "', _uint256ToString(uint256(t.designatedBoardSeats)), @@ -697,7 +636,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2RestrictionsRedemption(SeriesTerms storage t) internal view returns (string memory) { + function _buildRestrictionsRedemption(SeriesTerms storage t) internal view returns (string memory) { return string(abi.encodePacked( '"transferRestrictionCount": "', _uint256ToString(t.transferRestrictions.length), '", "isRedeemable": "', _boolToString(t.isRedeemable), @@ -707,7 +646,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Certificate(CertificateData memory c) internal pure returns (string memory) { + function _buildCertificate(CertificateData memory c) internal pure returns (string memory) { return string(abi.encodePacked( '"certificateNumber": "', _uint256ToString(c.certificateNumber), '", "numberOfShares": "', _uint256ToString(c.numberOfShares), @@ -719,77 +658,12 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildV2Issuer() internal view returns (string memory) { + function _buildIssuer() internal view returns (string memory) { return string(abi.encodePacked( '"issuerName": "', issuerName, '"' )); } - // ══════════════════════════════════════════════════════════════ - // Internal — V1 URI Builder (backward compatibility) - // ══════════════════════════════════════════════════════════════ - - function _buildV1URI(bytes memory data) internal pure returns (string memory) { - ShareData memory d = abi.decode(data, (ShareData)); - - string memory part1 = _buildV1IdentityAndEconomics(d); - string memory part2 = _buildV1ConversionAndVoting(d); - string memory part3 = _buildV1RestrictionsAndRedemption(d); - string memory part4 = _buildV1ProtectiveAndAuth(d); - - return string(abi.encodePacked( - ', "shareDetails": {', - '"version": "1", ', - part1, part2, part3, part4, - "}" - )); - } - - function _buildV1IdentityAndEconomics(ShareData memory d) internal pure returns (string memory) { - 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), - '", ' - )); - } - - function _buildV1ConversionAndVoting(ShareData memory d) internal pure returns (string memory) { - 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)), - '", ' - )); - } - - function _buildV1RestrictionsAndRedemption(ShareData memory d) internal pure returns (string memory) { - return string(abi.encodePacked( - '"transferRestrictionType": "', _transferRestrictionTypeToString(d.transferRestrictionType), - '", "isRedeemable": "', _boolToString(d.isRedeemable), - '", "redemptionPrice": "', _uint256ToString(d.redemptionPrice), - '", ' - )); - } - - function _buildV1ProtectiveAndAuth(ShareData memory d) internal pure returns (string memory) { - return string(abi.encodePacked( - '"hasProtectiveProvisions": "', _boolToString(d.hasProtectiveProvisions), - '", "protectiveProvisionThreshold": "', _uint256ToString(d.protectiveProvisionThreshold), - '", "authorizedShares": "', _uint256ToString(d.authorizedShares), - '"' - )); - } - // ══════════════════════════════════════════════════════════════ // Internal — String Helpers // ══════════════════════════════════════════════════════════════ @@ -800,12 +674,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { return "Custom"; } - function _shareClassToString(ShareClass c) internal pure returns (string memory) { - if (c == ShareClass.Common) return "Common"; - if (c == ShareClass.Preferred) return "Preferred"; - return "Unknown"; - } - function _liquidationPrefTypeToString(LiquidationPreferenceType t) internal pure returns (string memory) { if (t == LiquidationPreferenceType.NonParticipating) return "NonParticipating"; if (t == LiquidationPreferenceType.Participating) return "Participating"; From 9dcc26895e493d6fd4f255c161395b7ad99bb6f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 16:24:54 +0000 Subject: [PATCH 07/21] Implement full ShareExtension redesign per revised evaluation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (HIGH priority): - Stock split mechanism with atomic price/share adjustment, split history, and batch support (recordStockSplit, recordStockSplitBatch, getSplitHistory) - Separate dynamic arrays from SeriesTerms into dedicated CRUD mappings: _conversionTriggers, _specialVotingRights, _transferRestrictions with add/remove/get/count functions for each - Series terms versioning: version counter, historical terms hashes, versioned update events Phase 2 (MEDIUM priority): - Expand MandatoryConversionTrigger with primaryThreshold, secondaryThreshold, additionalConditions for compound conditions - Add TransferRestrictionException sub-struct with exceptionType, exceptionText, requiresEvidence - Expand CertificateData with ShareRepresentationType (DGCL §158), holdingPeriodStartDate, holdingPeriodTackingApplied (Rule 144) - Add view helpers: getConversionRatio, getPaymentPercentage, computeAccruedDividends - Legend removal request audit trail (requestLegendRemoval + event) Phase 3 (LOW priority): - Add 8 MATTER_* constants for protective provisions (COI §3.3) - Add SECURITIES_ACT_LEGEND standard legend constant - Add stateOfIncorporation to issuer data (DGCL §158) - Add pagination helpers (getSeriesCount, getSeriesIdsPaginated) - Add NVCA optional fields to SeriesTerms (pay-to-play, registration rights, pro-rata, information rights, drag-along) Phase 4 (code quality): - Update URI builder to read from separated mappings - Update validation (pure where possible, removed stale trigger check from SeriesTerms since triggers are now separate) - Adjust storage layout: 13 slots used + __gap[17] = 30 total https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 476 ++++++++++++++++++---- 1 file changed, 400 insertions(+), 76 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index e7084a55..3e52006e 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -47,7 +47,7 @@ import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; // ══════════════════════════════════════════════════════════════════════════════ -// Enums (file scope for potential centralization into CyberCorpConstants.sol) +// Enums // ══════════════════════════════════════════════════════════════════════════════ /// @notice Liquidation preference payout structure @@ -73,8 +73,6 @@ enum DividendType { } /// @notice Transfer restriction regime applicable to shares. -/// NOTE: Rule144Eligible removed — that is a holder/time condition, not a series designation. -/// SecuritiesActRestriction added — the standard "not registered" restricted-securities legend. enum TransferRestrictionType { None, BoardConsentRequired, // Section 8.9(a) of Bylaws — no transfer without board consent @@ -103,28 +101,36 @@ enum MandatoryConversionTriggerType { /// @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. These are distinct: -/// - ClassWide: all series within the same share class vote together (e.g., all Preferred) -/// - SeriesSpecific: only holders of this specific series vote (e.g., Series A alone) +/// 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 // ══════════════════════════════════════════════════════════════════════════════ -/// @notice Mandatory/automatic conversion trigger definition +/// @notice Mandatory/automatic conversion trigger definition. +/// Supports compound conditions (e.g., QualifiedIPO requires price AND proceeds AND listing). struct MandatoryConversionTrigger { MandatoryConversionTriggerType triggerType; - uint256 thresholdValue; // e.g., IPO price threshold (18 dec), vote percentage (4 dec) - string description; // human-readable description of the trigger condition + 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., keccak256("CHARTER_AMENDMENT"), keccak256("MERGER_APPROVAL") + 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 @@ -132,17 +138,35 @@ struct SpecialVotingRight { 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) @@ -173,19 +197,17 @@ struct SeriesTerms { AntiDilutionType antiDilutionType; bool allowsFractionalConversion; // whether fractional shares may be issued on conversion bool hasMandatoryConversion; - MandatoryConversionTrigger[] mandatoryConversionTriggers; + // NOTE: mandatoryConversionTriggers stored separately in _conversionTriggers mapping // --- 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 - // (e.g., all Preferred series voting together as a single class) bool hasSeriesVotingRights; // whether this series can vote separately as its own series - // (e.g., Series A alone voting on matters requiring Series A consent) - SpecialVotingRight[] specialVotingRights; // each entry specifies its own VotingScope + // NOTE: specialVotingRights stored separately in _specialVotingRights mapping // --- Transfer Restrictions --- - TransferRestriction[] transferRestrictions; + // NOTE: transferRestrictions stored separately in _transferRestrictions mapping // --- Redemption --- bool isRedeemable; @@ -193,9 +215,19 @@ struct SeriesTerms { 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 + + // --- 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 } -/// @notice Per-certificate metadata (V2). References canonical SeriesTerms via seriesId. +/// @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 @@ -206,6 +238,9 @@ struct CertificateData { 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 } // ══════════════════════════════════════════════════════════════════════════════ @@ -226,39 +261,86 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { 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, string fieldChanged); + 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 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 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]. - // 6 slots used + 24 reserved = 30 total (preserves layout). + // Slots used + reserved = 30 total (preserves layout). // ────────────────────────────────────────────────────────────── /// @notice Canonical series terms registry - mapping(bytes32 => SeriesTerms) internal _seriesRegistry; // slot 0 + mapping(bytes32 => SeriesTerms) internal _seriesRegistry; // slot 0 /// @notice Ordered list of series IDs for enumeration - bytes32[] public seriesIds; // slot 1 + bytes32[] public seriesIds; // slot 1 /// @notice O(1) existence check for series - mapping(bytes32 => bool) public seriesExists; // slot 2 + mapping(bytes32 => bool) public seriesExists; // slot 2 /// @notice Per-certificate legend texts keyed by token ID - mapping(uint256 => string[]) internal _certificateLegends; // slot 3 + mapping(uint256 => string[]) internal _certificateLegends; // slot 3 /// @notice Parallel array of legend hashes for efficient on-chain verification - mapping(uint256 => bytes32[]) internal _certificateLegendHashes; // slot 4 + mapping(uint256 => bytes32[]) internal _certificateLegendHashes; // slot 4 /// @notice Issuer name — changeable by board/owner (covers name changes, mergers) - string public issuerName; // slot 5 + string public issuerName; // slot 5 + + // --- Phase 1 new storage (slots 6-14) --- + + /// @notice Separated dynamic arrays: mandatory conversion triggers per series + mapping(bytes32 => MandatoryConversionTrigger[]) internal _conversionTriggers; // slot 6 + /// @notice Separated dynamic arrays: special voting rights per series + mapping(bytes32 => SpecialVotingRight[]) internal _specialVotingRights; // slot 7 + /// @notice Separated dynamic arrays: transfer restrictions per series + mapping(bytes32 => TransferRestriction[]) internal _transferRestrictions; // slot 8 + /// @notice Series terms version counter (incremented on each update) + mapping(bytes32 => uint256) public seriesTermsVersion; // slot 9 + /// @notice Historical terms hashes: seriesId => version => keccak256 of old terms + mapping(bytes32 => mapping(uint256 => bytes32)) public seriesTermsHistoryHashes; // slot 10 + /// @notice Stock split history per series + mapping(bytes32 => SplitRecord[]) internal _splitHistory; // slot 11 + /// @notice State of incorporation (DGCL §158 compliance) + string public stateOfIncorporation; // slot 12 /// @dev Reserved storage for future upgrades - uint256[24] private __gap; // slots 6-29 + uint256[17] private __gap; // slots 13-29 // ────────────────────────────────────────────────────────────── // Initialization @@ -273,9 +355,8 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // Series Management (onlyOwner / board-authorized) // ══════════════════════════════════════════════════════════════ - /// @notice Register a new series with canonical terms - /// @param seriesId Unique identifier for the series (immutable once created) - /// @param terms The full series terms struct + /// @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"); @@ -286,22 +367,28 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { _seriesRegistry[seriesId] = terms; seriesIds.push(seriesId); seriesExists[seriesId] = true; + seriesTermsVersion[seriesId] = 1; emit SeriesCreated(seriesId, terms.shareClassKey, terms.seriesName); } - /// @notice Replace the full terms for an existing series - /// @param seriesId The series to update - /// @param terms The new full series terms + /// @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, "ALL"); + emit SeriesTermsUpdated(seriesId, currentVersion + 1, oldHash); } /// @notice Update conversion price independently (e.g., anti-dilution adjustment) @@ -332,7 +419,190 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { function updateSeriesName(bytes32 seriesId, string calldata newName) external onlyOwner { require(seriesExists[seriesId], "ShareExtension: series does not exist"); _seriesRegistry[seriesId].seriesName = newName; - emit SeriesTermsUpdated(seriesId, "seriesName"); + } + + // ══════════════════════════════════════════════════════════════ + // 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 { + 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 (price-based thresholds scale down) + MandatoryConversionTrigger[] storage triggers = _conversionTriggers[seriesId]; + for (uint256 i = 0; i < triggers.length; i++) { + if (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 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++) { + // Inline the logic to avoid external call overhead; reuse internal checks + this.recordStockSplit(_seriesIds[i], splitNumerator, splitDenominator, sourceAuthorityURI); + } + } + + /// @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; } // ══════════════════════════════════════════════════════════════ @@ -354,8 +624,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // ══════════════════════════════════════════════════════════════ /// @notice Add a legend to a specific certificate - /// @param tokenId The ERC-721 token ID - /// @param legendText The full legal legend/restriction notice text function addLegend(uint256 tokenId, string calldata legendText) external onlyOwner { bytes32 h = keccak256(bytes(legendText)); _certificateLegends[tokenId].push(legendText); @@ -365,8 +633,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } /// @notice Remove a legend from a specific certificate by index (swap-and-pop) - /// @param tokenId The ERC-721 token ID - /// @param legendIndex Index of the legend to remove function removeLegend(uint256 tokenId, uint256 legendIndex) external onlyOwner { string[] storage legends = _certificateLegends[tokenId]; bytes32[] storage hashes = _certificateLegendHashes[tokenId]; @@ -374,7 +640,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { bytes32 removedHash = hashes[legendIndex]; - // Swap with last element and pop uint256 lastIdx = legends.length - 1; if (legendIndex != lastIdx) { legends[legendIndex] = legends[lastIdx]; @@ -386,6 +651,12 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { emit LegendRemoved(tokenId, legendIndex, removedHash); } + /// @notice Request legend removal (informational audit trail; actual removal requires removeLegend) + function requestLegendRemoval(uint256 tokenId, uint256 legendIndex, string calldata justification) external { + require(legendIndex < _certificateLegends[tokenId].length, "ShareExtension: legend index out of bounds"); + emit LegendRemovalRequested(tokenId, legendIndex, justification); + } + /// @notice Get all legends for a certificate function getLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes) { return (_certificateLegends[tokenId], _certificateLegendHashes[tokenId]); @@ -396,7 +667,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { function initializeLegends(uint256 tokenId, bytes32 seriesId) external onlyOwner { require(seriesExists[seriesId], "ShareExtension: series does not exist"); - TransferRestriction[] storage restrictions = _seriesRegistry[seriesId].transferRestrictions; + TransferRestriction[] storage restrictions = _transferRestrictions[seriesId]; for (uint256 i = 0; i < restrictions.length; i++) { if (bytes(restrictions[i].restrictionText).length > 0) { bytes32 h = keccak256(bytes(restrictions[i].restrictionText)); @@ -411,13 +682,20 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // Issuer Identity // ══════════════════════════════════════════════════════════════ - /// @notice Set or update the issuer name (single authoritative value for all certs) + /// @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 // ══════════════════════════════════════════════════════════════ @@ -433,9 +711,27 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { return seriesIds; } - /// @notice Get full share info: series terms + decoded certificate data + legends - /// @param certExtensionData The encoded CertificateData bytes - /// @param tokenId The ERC-721 token ID (for legend lookup) + /// @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 @@ -447,12 +743,50 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { legends = _certificateLegends[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; + // Simple accrual: rate * OIP * shares * elapsed / (365 days * 1e18) + // Rate is already in 18 decimals as a fraction (8% = 8e16) + accrued = (s.dividendRate * s.originalIssuePrice * numberOfShares * elapsed) + / (365 days * PRICE_PRECISION); + } + // ══════════════════════════════════════════════════════════════ // Validation // ══════════════════════════════════════════════════════════════ /// @notice Validate series terms without modifying state - /// @dev `view` because it may check seriesExists for targetConversionSeriesId function validateSeriesTerms(SeriesTerms memory terms) external view returns (bool valid, string memory error) { return _validateSeriesTermsInternal(terms); } @@ -470,8 +804,8 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { return extensionType == EXTENSION_TYPE; } - /// @notice Render extension data as a JSON fragment. - /// @dev `view` (not `pure`) because it reads seriesRegistry and issuerName. + /// @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 ""; @@ -485,71 +819,52 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // Internal — Validation // ══════════════════════════════════════════════════════════════ - function _validateSeriesTermsInternal(SeriesTerms memory t) internal view returns (bool, string memory) { - // 9. authorizedShares must be > 0 + function _validateSeriesTermsInternal(SeriesTerms memory t) internal pure returns (bool, string memory) { if (t.authorizedShares == 0) return (false, "ShareExtension: authorizedShares must be > 0"); - - // 10. parValue should be > 0 if (t.parValue == 0) return (false, "ShareExtension: parValue must be > 0"); - // 1. If not convertible, conversion fields must be zero/empty 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.mandatoryConversionTriggers.length > 0) return (false, "ShareExtension: mandatoryConversionTriggers must be empty when not convertible"); if (t.hasMandatoryConversion) return (false, "ShareExtension: hasMandatoryConversion must be false when not convertible"); } - // 2. If convertible, targetConversionSeriesId must be non-zero if (t.isConvertible) { if (t.targetConversionSeriesId == bytes32(0)) return (false, "ShareExtension: targetConversionSeriesId must be non-zero when convertible"); } - // 3. If dividendType == None, dividendRate must be 0 if (t.dividendType == DividendType.None) { if (t.dividendRate != 0) return (false, "ShareExtension: dividendRate must be 0 when dividendType is None"); } - // 4. If dividendType == Cumulative, accrualStartDate should be non-zero if (t.dividendType == DividendType.Cumulative) { if (t.dividendAccrualStartDate == 0) return (false, "ShareExtension: dividendAccrualStartDate should be non-zero for Cumulative dividends"); } - // 5. If not CappedParticipating, participationCap must be 0 if (t.liquidationPreferenceType != LiquidationPreferenceType.CappedParticipating) { if (t.participationCap != 0) return (false, "ShareExtension: participationCap must be 0 when not CappedParticipating"); } - // 6. If CappedParticipating, participationCap must be > 0 if (t.liquidationPreferenceType == LiquidationPreferenceType.CappedParticipating) { if (t.participationCap == 0) return (false, "ShareExtension: participationCap must be > 0 when CappedParticipating"); } - // 7. If not redeemable, redemptionPrice must be 0 and redemptionType must be None 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"); } - // 11. hasMandatoryConversion requires at least one trigger - if (t.hasMandatoryConversion) { - if (t.mandatoryConversionTriggers.length == 0) return (false, "ShareExtension: hasMandatoryConversion requires at least one trigger"); - } - return (true, ""); } function _validateCertificateDataInternal(CertificateData memory d) internal view returns (bool, string memory) { - // 13. seriesId must reference an existing series if (!seriesExists[d.seriesId]) return (false, "ShareExtension: seriesId does not reference an existing series"); - // 14. If partly paid, amountPaid < totalConsideration and totalConsideration > 0 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"); } - // 15. If not partly paid, amountPaid and totalConsideration should both be 0 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"); @@ -568,9 +883,9 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { string memory p1 = _buildIdentity(terms); string memory p2 = _buildEconomics(terms); string memory p3 = _buildDividends(terms); - string memory p4 = _buildConversion(terms); - string memory p5 = _buildVoting(terms); - string memory p6 = _buildRestrictionsRedemption(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(); @@ -613,32 +928,32 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { )); } - function _buildConversion(SeriesTerms storage t) internal view returns (string memory) { + function _buildConversion(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { return string(abi.encodePacked( '"isConvertible": "', _boolToString(t.isConvertible), '", "conversionPrice": "', _uint256ToString(t.conversionPrice), '", "antiDilutionType": "', _antiDilutionTypeToString(t.antiDilutionType), '", "allowsFractionalConversion": "', _boolToString(t.allowsFractionalConversion), '", "hasMandatoryConversion": "', _boolToString(t.hasMandatoryConversion), - '", "mandatoryConversionTriggerCount": "', _uint256ToString(t.mandatoryConversionTriggers.length), + '", "mandatoryConversionTriggerCount": "', _uint256ToString(_conversionTriggers[seriesId].length), '", ' )); } - function _buildVoting(SeriesTerms storage t) internal view returns (string memory) { + function _buildVoting(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { return string(abi.encodePacked( '"votesPerShare": "', _uint256ToString(t.votesPerShare), '", "designatedBoardSeats": "', _uint256ToString(uint256(t.designatedBoardSeats)), '", "hasClassVotingRights": "', _boolToString(t.hasClassVotingRights), '", "hasSeriesVotingRights": "', _boolToString(t.hasSeriesVotingRights), - '", "specialVotingRightsCount": "', _uint256ToString(t.specialVotingRights.length), + '", "specialVotingRightsCount": "', _uint256ToString(_specialVotingRights[seriesId].length), '", ' )); } - function _buildRestrictionsRedemption(SeriesTerms storage t) internal view returns (string memory) { + function _buildRestrictionsRedemption(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { return string(abi.encodePacked( - '"transferRestrictionCount": "', _uint256ToString(t.transferRestrictions.length), + '"transferRestrictionCount": "', _uint256ToString(_transferRestrictions[seriesId].length), '", "isRedeemable": "', _boolToString(t.isRedeemable), '", "redemptionType": "', _redemptionTypeToString(t.redemptionType), '", "redemptionPrice": "', _uint256ToString(t.redemptionPrice), @@ -654,13 +969,15 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { '", "isPartlyPaid": "', _boolToString(c.isPartlyPaid), '", "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, '"' + '"issuerName": "', issuerName, + '", "stateOfIncorporation": "', stateOfIncorporation, '"' )); } @@ -715,6 +1032,13 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { 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"; } From de048da7965f9bee13ea32ec4f5b25386010e9f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 16:42:54 +0000 Subject: [PATCH 08/21] Reject zero conversionPrice for convertible series Add non-zero guard in updateConversionPrice() and in _validateSeriesTermsInternal() to prevent storing convertible terms with an undefined conversion ratio (OIP / 0). https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 3e52006e..2bd899c9 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -396,6 +396,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { 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; @@ -830,6 +831,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } 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"); } From 2763da17972bb0d273a570b7747ed15eb87fbed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 16:47:16 +0000 Subject: [PATCH 09/21] Only scale price-denominated triggers during stock splits The split handler was rescaling primaryThreshold for all trigger types, but only QualifiedIPO triggers store per-share price thresholds. ClassVote, DeemedLiquidation, and Custom triggers may store non-price values (vote percentages, aggregate amounts) that should not be adjusted. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 2bd899c9..95ea0124 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -459,10 +459,10 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { // Adjust share count UP s.authorizedShares = (s.authorizedShares * splitNumerator) / splitDenominator; - // Adjust conversion trigger thresholds (price-based thresholds scale down) + // 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].primaryThreshold > 0) { + 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 From 8bef17648afa69fa22669095193041928c07fd10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 16:51:37 +0000 Subject: [PATCH 10/21] Extract _recordStockSplit internal to fix batch self-call revert recordStockSplitBatch used this.recordStockSplit() which is an external call where msg.sender becomes the contract itself, causing the onlyOwner check to always revert. Extract the logic into an internal _recordStockSplit function called by both external entry points. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 34 ++++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 95ea0124..b5dcbf40 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -437,6 +437,27 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { 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"); @@ -479,19 +500,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { emit StockSplitRecorded(seriesId, splitNumerator, splitDenominator, oldOIP, s.originalIssuePrice, oldParValue, s.parValue); } - /// @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++) { - // Inline the logic to avoid external call overhead; reuse internal checks - this.recordStockSplit(_seriesIds[i], splitNumerator, splitDenominator, sourceAuthorityURI); - } - } - /// @notice Get split history for a series function getSplitHistory(bytes32 seriesId) external view returns (SplitRecord[] memory) { return _splitHistory[seriesId]; From 6235595fc05b4baa67ca66fd42d2c3cc11846e71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:00:42 +0000 Subject: [PATCH 11/21] Backport legend hash tracking, events, and audit trail to CyberCertPrinter Aligns CyberCertPrinter with ShareExtension's more mature legend patterns: - Add parallel certLegendHashes/defaultLegendHashes for on-chain verification - Emit LegendAdded/LegendRemoved/DefaultLegendAdded/DefaultLegendRemoved events - Add requestCertLegendRemoval() for removal audit trail - Add getCertLegends() bulk getter returning texts + hashes - Seed hashes in initialize() and both safeMint paths - Access control remains onlyIssuanceManager https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/CyberCertPrinter.sol | 59 ++++++++++++++++++++----- src/storage/CyberCertPrinterStorage.sol | 3 +- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol index cb937684..b75404bf 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() { @@ -109,6 +114,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 +152,26 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { ) external onlyIssuanceManager returns (uint256) { _safeMint(to, tokenId); - CyberCertPrinterStorage.cyberCertStorage().certLegend[tokenId] = CyberCertPrinterStorage.cyberCertStorage().defaultLegend; - CyberCertPrinterStorage.cyberCertStorage().certificateDetails[tokenId] = details; + CyberCertPrinterStorage.CyberCertStorage 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; + 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; @@ -385,20 +397,25 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { function addDefaultLegend(string memory newLegend) external onlyIssuanceManager { 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 { 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) { @@ -414,33 +431,51 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { function addCertLegend(uint256 tokenId, string memory newLegend) external onlyIssuanceManager { 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 { 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/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 From 0dcd787d6c2b1bfcef7e27dc2a0faa692dc0540e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:12:55 +0000 Subject: [PATCH 12/21] Delegate legend management from ShareExtension to CyberCertPrinter ShareExtension no longer owns per-certificate legend storage. CyberCertPrinter is the single source of truth for legend CRUD, events, and hash tracking. ShareExtension changes: - Remove addLegend, removeLegend, requestLegendRemoval (use CyberCertPrinter) - Remove legend events (emitted by CyberCertPrinter) - Deprecate _certificateLegends/_certificateLegendHashes (slots preserved) - Add certPrinter address + setCertPrinter setter - initializeLegends now delegates to CyberCertPrinter.addCertLegend - getFullShareInfo and getLegends read from CyberCertPrinter CyberCertPrinter changes: - Add onlyIssuanceManagerOrExtension modifier - addCertLegend now accepts calls from the registered extension https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/CyberCertPrinter.sol | 8 +- src/storage/extensions/ShareExtension.sol | 89 +++++++++-------------- 2 files changed, 41 insertions(+), 56 deletions(-) diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol index b75404bf..1e9ff8eb 100644 --- a/src/CyberCertPrinter.sol +++ b/src/CyberCertPrinter.sol @@ -102,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(); } @@ -429,7 +435,7 @@ 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 { CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); bytes32 h = keccak256(bytes(newLegend)); s.certLegend[tokenId].push(newLegend); diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index b5dcbf40..8571a368 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -46,6 +46,12 @@ import "./ICertificateExtension.sol"; import "../../CyberCorpConstants.sol"; import "../../libs/auth.sol"; +/// @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 // ══════════════════════════════════════════════════════════════════════════════ @@ -297,9 +303,6 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { event SpecialVotingRightRemoved(bytes32 indexed seriesId, uint256 index, bytes32 matterType); event TransferRestrictionAdded(bytes32 indexed seriesId, uint256 index); event TransferRestrictionRemoved(bytes32 indexed seriesId, uint256 index); - 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 IssuerNameUpdated(string oldName, string newName); event StateOfIncorporationUpdated(string oldState, string newState); @@ -315,10 +318,10 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { bytes32[] public seriesIds; // slot 1 /// @notice O(1) existence check for series mapping(bytes32 => bool) public seriesExists; // slot 2 - /// @notice Per-certificate legend texts keyed by token ID - mapping(uint256 => string[]) internal _certificateLegends; // slot 3 - /// @notice Parallel array of legend hashes for efficient on-chain verification - mapping(uint256 => bytes32[]) internal _certificateLegendHashes; // slot 4 + /// @dev DEPRECATED — legends now managed by CyberCertPrinter. Slot preserved for upgrade safety. + mapping(uint256 => string[]) internal _deprecated_certificateLegends; // slot 3 + /// @dev DEPRECATED — legend hashes now managed by CyberCertPrinter. Slot preserved for upgrade safety. + mapping(uint256 => bytes32[]) internal _deprecated_certificateLegendHashes; // slot 4 /// @notice Issuer name — changeable by board/owner (covers name changes, mergers) string public issuerName; // slot 5 @@ -338,9 +341,11 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { mapping(bytes32 => SplitRecord[]) internal _splitHistory; // slot 11 /// @notice State of incorporation (DGCL §158 compliance) string public stateOfIncorporation; // slot 12 + /// @notice CyberCertPrinter address — canonical legend storage owner + address public certPrinter; // slot 13 /// @dev Reserved storage for future upgrades - uint256[17] private __gap; // slots 13-29 + uint256[16] private __gap; // slots 14-29 // ────────────────────────────────────────────────────────────── // Initialization @@ -351,6 +356,12 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { __BorgAuthACL_init(_auth); } + /// @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) // ══════════════════════════════════════════════════════════════ @@ -629,64 +640,30 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } // ══════════════════════════════════════════════════════════════ - // Legend Management (per-certificate) + // Legend Management (delegated to CyberCertPrinter) // ══════════════════════════════════════════════════════════════ - /// @notice Add a legend to a specific certificate - function addLegend(uint256 tokenId, string calldata legendText) external onlyOwner { - bytes32 h = keccak256(bytes(legendText)); - _certificateLegends[tokenId].push(legendText); - _certificateLegendHashes[tokenId].push(h); - - emit LegendAdded(tokenId, _certificateLegends[tokenId].length - 1, h); - } - - /// @notice Remove a legend from a specific certificate by index (swap-and-pop) - function removeLegend(uint256 tokenId, uint256 legendIndex) external onlyOwner { - string[] storage legends = _certificateLegends[tokenId]; - bytes32[] storage hashes = _certificateLegendHashes[tokenId]; - require(legendIndex < legends.length, "ShareExtension: legend index out of bounds"); - - bytes32 removedHash = hashes[legendIndex]; - - uint256 lastIdx = legends.length - 1; - if (legendIndex != lastIdx) { - legends[legendIndex] = legends[lastIdx]; - hashes[legendIndex] = hashes[lastIdx]; - } - legends.pop(); - hashes.pop(); - - emit LegendRemoved(tokenId, legendIndex, removedHash); - } - - /// @notice Request legend removal (informational audit trail; actual removal requires removeLegend) - function requestLegendRemoval(uint256 tokenId, uint256 legendIndex, string calldata justification) external { - require(legendIndex < _certificateLegends[tokenId].length, "ShareExtension: legend index out of bounds"); - emit LegendRemovalRequested(tokenId, legendIndex, justification); - } - - /// @notice Get all legends for a certificate - function getLegends(uint256 tokenId) external view returns (string[] memory texts, bytes32[] memory hashes) { - return (_certificateLegends[tokenId], _certificateLegendHashes[tokenId]); - } - - /// @notice Initialize a certificate's legends from its series' default transfer restrictions - /// @dev Should be called at mint time to populate initial restriction notices + /// @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) { - bytes32 h = keccak256(bytes(restrictions[i].restrictionText)); - _certificateLegends[tokenId].push(restrictions[i].restrictionText); - _certificateLegendHashes[tokenId].push(h); - emit LegendAdded(tokenId, _certificateLegends[tokenId].length - 1, h); + 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 // ══════════════════════════════════════════════════════════════ @@ -749,7 +726,9 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { cert = abi.decode(certExtensionData, (CertificateData)); require(seriesExists[cert.seriesId], "ShareExtension: series does not exist"); terms = _seriesRegistry[cert.seriesId]; - legends = _certificateLegends[tokenId]; + if (certPrinter != address(0)) { + (legends, ) = ICertPrinterLegends(certPrinter).getCertLegends(tokenId); + } } /// @notice Compute the conversion ratio for a convertible series: OIP / conversionPrice From 7c871a1d9f25c84fdd24e288d75b1893706d104b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:19:42 +0000 Subject: [PATCH 13/21] Extract shared StringUtils library for uint256ToString All four extensions (SAFT, SAFTE, TokenWarrant, Share) had identical copies of uint256ToString. Extract to src/libs/StringUtils.sol and use `using StringUtils for uint256` in each extension. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/libs/StringUtils.sol | 27 ++++++++++++ src/storage/extensions/SAFTEExtension.sol | 40 +++++------------- src/storage/extensions/SAFTExtension.sol | 34 ++++----------- src/storage/extensions/ShareExtension.sol | 22 ++-------- .../extensions/TokenWarrantExtension.sol | 42 +++++-------------- 5 files changed, 58 insertions(+), 107 deletions(-) create mode 100644 src/libs/StringUtils.sol 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/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 8571a368..f3af358f 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.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"; /// @notice Minimal interface for CyberCertPrinter legend operations interface ICertPrinterLegends { @@ -254,6 +255,7 @@ struct CertificateData { // ══════════════════════════════════════════════════════════════════════════════ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { + using StringUtils for uint256; // ────────────────────────────────────────────────────────────── // Constants @@ -1033,25 +1035,7 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } 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"; From 8bbd1daeaf487cad8dbfa61cbb5468adc2d6c707 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:23:55 +0000 Subject: [PATCH 14/21] Honor dividendCompounding flag in computeAccruedDividends When SeriesTerms.dividendCompounding is true, apply annual compound interest instead of simple interest. Full years compound at (1 + rate)^n, and any partial year remainder accrues simple interest on the compounded principal. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index f3af358f..690ddc8f 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -766,10 +766,32 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { if (asOfTimestamp <= s.dividendAccrualStartDate) return 0; uint256 elapsed = asOfTimestamp - s.dividendAccrualStartDate; - // Simple accrual: rate * OIP * shares * elapsed / (365 days * 1e18) - // Rate is already in 18 decimals as a fraction (8% = 8e16) - accrued = (s.dividendRate * s.originalIssuePrice * numberOfShares * elapsed) - / (365 days * PRICE_PRECISION); + 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; + } } // ══════════════════════════════════════════════════════════════ From d97492004eb3c3fdbab9a01d98813933efecb37a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:25:51 +0000 Subject: [PATCH 15/21] Preserve custom shareClassKey identity in URI rendering _shareClassKeyToString previously collapsed all non-Common/Preferred keys to the literal "Custom", making distinct custom classes indistinguishable in metadata. Now renders the full bytes32 as a 0x-prefixed hex string so off-chain consumers can differentiate custom class identifiers. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index 690ddc8f..df0f1433 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -1001,7 +1001,20 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { function _shareClassKeyToString(bytes32 key) internal pure returns (string memory) { if (key == CLASS_COMMON) return "Common"; if (key == CLASS_PREFERRED) return "Preferred"; - return "Custom"; + 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) { From fccc4a22a51a98b5ec7272ef14af5b130c51ab6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 17:31:01 +0000 Subject: [PATCH 16/21] Condense URI output by omitting inactive sections for simple share classes When features are at their default/zero state (no liquidation preference, no dividends, not convertible, not redeemable, not partly paid), the URI builder now emits only the summary flag rather than all the detail fields. This keeps common stock metadata concise while preserving full detail for preferred series that actually use these features. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index df0f1433..bc1b10c4 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -922,6 +922,11 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } function _buildEconomics(SeriesTerms storage t) internal view returns (string memory) { + if (t.liquidationPreferenceMultiple == 0) { + return string(abi.encodePacked( + '"liquidationPreferenceMultiple": "0", ' + )); + } return string(abi.encodePacked( '"liquidationPreferenceMultiple": "', _uint256ToString(t.liquidationPreferenceMultiple), '", "liquidationPreferenceType": "', _liquidationPrefTypeToString(t.liquidationPreferenceType), @@ -932,6 +937,9 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } function _buildDividends(SeriesTerms storage t) internal view returns (string memory) { + if (t.dividendType == DividendType.None) { + return '"dividendType": "None", '; + } return string(abi.encodePacked( '"dividendType": "', _dividendTypeToString(t.dividendType), '", "dividendRate": "', _uint256ToString(t.dividendRate), @@ -942,8 +950,11 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } function _buildConversion(SeriesTerms storage t, bytes32 seriesId) internal view returns (string memory) { + if (!t.isConvertible) { + return '"isConvertible": "false", '; + } return string(abi.encodePacked( - '"isConvertible": "', _boolToString(t.isConvertible), + '"isConvertible": "true', '", "conversionPrice": "', _uint256ToString(t.conversionPrice), '", "antiDilutionType": "', _antiDilutionTypeToString(t.antiDilutionType), '", "allowsFractionalConversion": "', _boolToString(t.allowsFractionalConversion), @@ -965,9 +976,15 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } 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": "', _boolToString(t.isRedeemable), + '", "isRedeemable": "true', '", "redemptionType": "', _redemptionTypeToString(t.redemptionType), '", "redemptionPrice": "', _uint256ToString(t.redemptionPrice), '", ' @@ -975,11 +992,21 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { } 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": "', _boolToString(c.isPartlyPaid), + '", "isPartlyPaid": "true', '", "amountPaid": "', _uint256ToString(c.amountPaid), '", "totalConsideration": "', _uint256ToString(c.totalConsideration), '", "representationType": "', _representationTypeToString(c.representationType), From c5f8c7e1eb72dce65feb6f35451309d9507addf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 18:42:07 +0000 Subject: [PATCH 17/21] Add lazy legend-hash backfill for pre-upgrade proxies For already-deployed upgradeable proxies, initialize() is not re-run after the hash-tracking upgrade, so defaultLegendHashes stays empty while defaultLegend has entries. This causes safeMint to copy mismatched arrays and removeDefaultLegendAt/removeCertLegendAt to revert on out-of-bounds hash reads. Fix: add _ensureDefaultLegendHashes() and _ensureCertLegendHashes() that lazily compute and append missing hashes on first mutation. Called from safeMint, safeMintAndAssign, removeDefaultLegendAt, and removeCertLegendAt. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/CyberCertPrinter.sol | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol index 1e9ff8eb..fc1ea305 100644 --- a/src/CyberCertPrinter.sol +++ b/src/CyberCertPrinter.sol @@ -158,6 +158,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { ) external onlyIssuanceManager returns (uint256) { _safeMint(to, tokenId); + _ensureDefaultLegendHashes(); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); s.certLegend[tokenId] = s.defaultLegend; s.certLegendHashes[tokenId] = s.defaultLegendHashes; @@ -173,6 +174,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { CertificateDetails memory details ) external onlyIssuanceManager returns (uint256) { _safeMint(to, tokenId); + _ensureDefaultLegendHashes(); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); s.certLegend[tokenId] = s.defaultLegend; s.certLegendHashes[tokenId] = s.defaultLegendHashes; @@ -401,6 +403,30 @@ 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 { CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); bytes32 h = keccak256(bytes(newLegend)); @@ -410,6 +436,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { } function removeDefaultLegendAt(uint256 index) external onlyIssuanceManager { + _ensureDefaultLegendHashes(); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); if (index >= s.defaultLegend.length) revert InvalidLegendIndex(); @@ -444,6 +471,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { } function removeCertLegendAt(uint256 tokenId, uint256 index) external onlyIssuanceManager { + _ensureCertLegendHashes(tokenId); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); if (index >= s.certLegend[tokenId].length) revert InvalidLegendIndex(); From 205cef4142a5dd2ac5bcee7fb6fcece5bb8b1d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 18:53:20 +0000 Subject: [PATCH 18/21] Backfill legend hashes before appending in add functions addDefaultLegend and addCertLegend were missing the lazy backfill guard, so on upgraded proxies the new hash would land at index 0 of an empty array while pre-existing legend texts occupied indices 0..N. This permanently corrupts hash-to-legend alignment since _ensure*Hashes() assumes the existing prefix is valid and only appends from hashLen. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/CyberCertPrinter.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CyberCertPrinter.sol b/src/CyberCertPrinter.sol index fc1ea305..33c9ddf1 100644 --- a/src/CyberCertPrinter.sol +++ b/src/CyberCertPrinter.sol @@ -428,6 +428,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { } function addDefaultLegend(string memory newLegend) external onlyIssuanceManager { + _ensureDefaultLegendHashes(); CyberCertPrinterStorage.CyberCertStorage storage s = CyberCertPrinterStorage.cyberCertStorage(); bytes32 h = keccak256(bytes(newLegend)); s.defaultLegend.push(newLegend); @@ -463,6 +464,7 @@ contract CyberCertPrinter is Initializable, ERC721EnumerableUpgradeable { } 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); From ce5b61bb35942d9b9da0e2a9bd8e0940adc156df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 22:52:09 +0000 Subject: [PATCH 19/21] Seed series transfer-restriction legends automatically at mint time initializeLegends on ShareExtension copies series-specific restriction texts into newly minted certificates but was never called from any mint path, leaving share certificates without their legally required legends. Add _seedSeriesLegends() in IssuanceManager that extracts seriesId from extensionData, calls initializeLegends on the extension (try/catch for non-share extensions like SAFT/TokenWarrant), and invoke it from both createCert and createCertAndAssign. Also add getExtension to the ICyberCertPrinter interface. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/IssuanceManager.sol | 22 ++++++++++++++++++++++ src/interfaces/ICyberCertPrinter.sol | 1 + 2 files changed, 23 insertions(+) diff --git a/src/IssuanceManager.sol b/src/IssuanceManager.sol index 9cc5cf38..c0d70045 100644 --- a/src/IssuanceManager.sol +++ b/src/IssuanceManager.sol @@ -56,6 +56,11 @@ 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; +} + /// @title IssuanceManager /// @notice Manages the issuance and lifecycle of digital certificates representing securities and more /// @dev Implements UUPS upgradeable pattern and BorgAuth access control @@ -224,6 +229,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 +279,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 +292,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); } From dbbf422008a3cbae82adf5575f2b940be1fb75b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 23:31:17 +0000 Subject: [PATCH 20/21] Wire setCertPrinter on extension during cert printer creation createCertPrinter passes the extension address to the new CyberCertPrinter but never tells the extension about the cert printer, leaving certPrinter == address(0) on ShareExtension. This caused initializeLegends (and the mint-time _seedSeriesLegends) to always revert, silently swallowed by try/catch. Now createCertPrinter calls setCertPrinter on the extension after initialization (try/catch for non-share extensions that lack the function). https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/IssuanceManager.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/IssuanceManager.sol b/src/IssuanceManager.sol index c0d70045..2b0602ad 100644 --- a/src/IssuanceManager.sol +++ b/src/IssuanceManager.sol @@ -59,6 +59,7 @@ 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 @@ -202,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(), From f37ce4a0746b155e433ec75c18c8cbab036dceb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 23:43:02 +0000 Subject: [PATCH 21/21] Remove deprecated legacy storage slots and annotate no-prod-history ShareExtension was never deployed to production, so the deprecated _deprecated_certificateLegends and _deprecated_certificateLegendHashes mappings (slots 3-4) served no purpose. Removed them and re-numbered storage slots. Added NatSpec annotation documenting no prior production deployment so future reviewers know upgrade-compat constraints don't apply. https://claude.ai/code/session_01VKgobkdkBH4e2bGify38vT --- src/storage/extensions/ShareExtension.sol | 27 +++++++++++------------ 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/storage/extensions/ShareExtension.sol b/src/storage/extensions/ShareExtension.sol index bc1b10c4..d55b594e 100644 --- a/src/storage/extensions/ShareExtension.sol +++ b/src/storage/extensions/ShareExtension.sol @@ -254,6 +254,9 @@ struct CertificateData { // 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; @@ -320,31 +323,27 @@ contract ShareExtension is UUPSUpgradeable, ICertificateExtension, BorgAuthACL { bytes32[] public seriesIds; // slot 1 /// @notice O(1) existence check for series mapping(bytes32 => bool) public seriesExists; // slot 2 - /// @dev DEPRECATED — legends now managed by CyberCertPrinter. Slot preserved for upgrade safety. - mapping(uint256 => string[]) internal _deprecated_certificateLegends; // slot 3 - /// @dev DEPRECATED — legend hashes now managed by CyberCertPrinter. Slot preserved for upgrade safety. - mapping(uint256 => bytes32[]) internal _deprecated_certificateLegendHashes; // slot 4 /// @notice Issuer name — changeable by board/owner (covers name changes, mergers) - string public issuerName; // slot 5 + string public issuerName; // slot 3 - // --- Phase 1 new storage (slots 6-14) --- + // --- Separated dynamic arrays (slots 4-11) --- /// @notice Separated dynamic arrays: mandatory conversion triggers per series - mapping(bytes32 => MandatoryConversionTrigger[]) internal _conversionTriggers; // slot 6 + mapping(bytes32 => MandatoryConversionTrigger[]) internal _conversionTriggers; // slot 4 /// @notice Separated dynamic arrays: special voting rights per series - mapping(bytes32 => SpecialVotingRight[]) internal _specialVotingRights; // slot 7 + mapping(bytes32 => SpecialVotingRight[]) internal _specialVotingRights; // slot 5 /// @notice Separated dynamic arrays: transfer restrictions per series - mapping(bytes32 => TransferRestriction[]) internal _transferRestrictions; // slot 8 + mapping(bytes32 => TransferRestriction[]) internal _transferRestrictions; // slot 6 /// @notice Series terms version counter (incremented on each update) - mapping(bytes32 => uint256) public seriesTermsVersion; // slot 9 + 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 10 + mapping(bytes32 => mapping(uint256 => bytes32)) public seriesTermsHistoryHashes; // slot 8 /// @notice Stock split history per series - mapping(bytes32 => SplitRecord[]) internal _splitHistory; // slot 11 + mapping(bytes32 => SplitRecord[]) internal _splitHistory; // slot 9 /// @notice State of incorporation (DGCL §158 compliance) - string public stateOfIncorporation; // slot 12 + string public stateOfIncorporation; // slot 10 /// @notice CyberCertPrinter address — canonical legend storage owner - address public certPrinter; // slot 13 + address public certPrinter; // slot 11 /// @dev Reserved storage for future upgrades uint256[16] private __gap; // slots 14-29