Contract architecture, roles, state management, and function reference for the Stock Token suite.
The Stock Token suite consists of three deployable contracts and two interfaces. This page covers architecture, state layout, and the complete function reference for each contract.
StockToken holds a reference to IdentityRegistryUpgradeable and an optional BasicComplianceModule. On every transfer, the token contract calls isVerified() on the registry for both parties (unless one is an authorized contract), then calls canTransfer() on the compliance module if one is set.The identity registry can itself hold references to multiple compliance modules via addComplianceModule(). When the registry’s own canTransfer() is called (by external integrators checking compliance at the registry level), it iterates all registered modules. The token contract’s direct compliance module reference is separate - it allows per-token compliance configuration independent of the registry-level modules.
The initializer grants DEFAULT_ADMIN_ROLE and ISSUER_ROLE to the issuer address. LEGAL_OPERATOR_ROLE goes to a separate address representing the licensed legal entity. The split ratio starts at 1:1.
ISSUER_ROLE manages sub-issuers and controllers directly via addSubIssuer(), removeSubIssuer(), addController(), and removeController(). These convenience functions wrap the underlying AccessControl grant/revoke.
function issue(address to, uint256 amount, bytes32 reason) external onlyRole(SUB_ISSUER_ROLE) whenNotPaused nonReentrant
Mints amount tokens to to. The recipient must be identity-verified or an authorized contract. The sub-issuer’s net outstanding counter increments by amount; if a cap is set and would be exceeded, the call reverts with SubIssuerCapExceeded.The reason parameter is a bytes32 code for audit trails - define your own encoding scheme or use standardized codes.
Issues tokens to up to 200 recipients in a single transaction. Processing stops early if gas drops below 80,000 per item. Emits BatchIssueCompleted(processedCount, total, reason) so callers can detect partial completion.
function redeem(uint256 amount, bytes32 reason) external whenNotPaused nonReentrant
Burns amount from the caller’s balance. Restricted to ISSUER_ROLE and SUB_ISSUER_ROLE - regular holders cannot self-redeem (eWpG register control requirement). If the caller is a sub-issuer, their outstanding counter decrements; the call reverts with SubIssuerRedemptionExceeded if amount exceeds their net outstanding.
Burns tokens from a specific account. The sub-issuer’s outstanding counter decrements, limiting how much any single sub-issuer can redeem. Only unfrozen (transferable) tokens can be redeemed.
The standard ERC-20 transfer and transferFrom functions are overridden to call _validateTransfer before execution. This internal function calls canTransfer and reverts if the result is not SUCCESS.
Copy
function canTransfer(address from, address to, uint256 amount) public view returns (TransferRestrictionCode code, string memory reason)
Pre-checks whether a transfer would succeed. Returns a machine-readable TransferRestrictionCode and a human-readable reason string. The check order:
Freezing locks a specified amount on an account. The transferableBalance of that account decreases accordingly. Frozen amounts cannot exceed the account’s total balance. Unfreezing requires that the amount does not exceed the currently frozen amount.
function forceTransfer( address from, address to, uint256 amount, bytes32 reason) external onlyRole(CONTROLLER_ROLE) nonReentrant
Moves tokens between addresses regardless of sender approval, for regulatory seizure or recovery. The recipient must still be identity-verified or an authorized contract. If the sender has frozen tokens, the frozen amount is reduced proportionally (by the lesser of amount and frozen).
Executes a forward stock split. Only forward splits are supported (numerator must exceed denominator). For each holder in the array, the contract calculates (currentBalance * numerator) / denominator, mints the difference, and adjusts frozen token amounts proportionally (floor division).The cumulative split ratio is updated and reduced by GCD after each split. Query it via splitRatio().
Reverse splits are not supported because integer division would destroy fractional shares. Use redemption and re-issuance for reverse splits. If any holders are omitted from the array, their balances will not be adjusted - the issuer is legally required to maintain a complete holder register under eWpG.
function initialize(address gnosisSafe, address legalOperator) external initializer
The gnosisSafe address receives DEFAULT_ADMIN_ROLE and manages all role assignments. The legalOperator receives LEGAL_OPERATOR_ROLE for upgrade authorization.
Identities move through a defined lifecycle: verification, optional updates, soft expiry (KYC refresh needed), hard expiry (transfers blocked), and revocation.
Copy
function verifyIdentity( address user, bytes32 kycHash, // SHA256 of KYC data (zero-knowledge) InvestorType investorType, // RETAIL, PROFESSIONAL, or ELIGIBLE_COUNTERPARTY uint64 softExpiry, // When KYC needs refresh uint64 hardExpiry // When KYC becomes invalid for transfers) external onlyRole(KYC_PROVIDER_ROLE)
Batch verification is available via batchVerifyIdentities() for up to 500 identities per call, with gas-aware early termination and per-entry validation. Failed entries are returned as an index array.
Show KYCStatus enum
Copy
enum KYCStatus { ACTIVE, // Valid, no issues SOFT_EXPIRED, // Needs refresh, transfers still allowed HARD_EXPIRED, // Transfers blocked until renewed REVOKED // Permanently deactivated}
Status is calculated dynamically from expiry timestamps, not stored. SOFT_EXPIRED does not block transfers - it signals that the KYC provider should schedule a refresh.
function canTransfer( address from, address to, uint256 amount, uint256 fromBalance, uint256 toBalance) external view returns (TransferCheckResult memory)
Returns a TransferCheckResult with allowed, reason (machine-readable TransferRejectionReason), and the KYC status of both parties. The check runs identity validation for both parties, then iterates all registered compliance modules. The registry fails closed on compliance module errors.
function freezeAddress(address user) external onlyRole(COMPLIANCE_ROLE)function unfreezeAddress(address user) external onlyRole(COMPLIANCE_ROLE)
Freezing an address at the registry level blocks all transfers for that identity across all tokens using this registry. This is distinct from token-level freezing (which locks a specific amount on one token).
Stateless compliance module that enforces holding limits and lockup periods. Deployed per token contract, controlled by the token contract (no separate admin keys).
constructor( address _tokenContract, // Token that controls this module uint256 _defaultMaxHolding, // Global default max (0 = unlimited) uint256 _defaultMinHolding // Global default min)
The module checks three conditions on every canTransfer call:
Sender lockup. If the sender’s lockup has not expired, the transfer is rejected with SENDER_LOCKED.
Sender minimum holding. After the transfer, if the sender retains a non-zero balance below the minimum, the transfer is rejected with BELOW_MIN_HOLDING. Emptying an account entirely (balance going to zero) is always allowed.
Recipient maximum holding. If the recipient’s balance after the transfer exceeds the maximum, the transfer is rejected with RECIPIENT_EXCEEDS_MAX.
Per-user overrides take precedence over defaults. Set per-user values to 0 to fall back to the global default.