Skip to main content
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.

Contract Relationships

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.

StockToken

UUPS-upgradeable ERC-20 with transfer restrictions, corporate actions, and sub-issuer architecture.

Initialization

function initialize(
    string memory name_,        // e.g. "Acme Corp Class A"
    string memory symbol_,      // e.g. "ACME-A"
    string memory isin_,        // ISIN identifier
    string memory metadataURI_, // Off-chain metadata URI
    address identityRegistry_,  // IdentityRegistry contract address
    address issuer_,            // Receives ISSUER_ROLE + DEFAULT_ADMIN_ROLE
    address legalOperator_      // Receives LEGAL_OPERATOR_ROLE (upgrade authority)
) external initializer
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.

Roles

State Variables

VariableTypeDescription
_isinstringISIN identifier for the security
_metadataURIstringURI to off-chain metadata (prospectus, term sheet)
_identityRegistryIIdentityRegistryKYC/AML verification contract
_complianceModuleIComplianceModuleOptional compliance rules (holding limits, lockups)
_frozenTokensmapping(address => uint256)Frozen token amount per account
_splitMultiplierNumeratoruint256Cumulative split ratio numerator
_splitMultiplierDenominatoruint256Cumulative split ratio denominator
_authorizedContractsmapping(address => bool)KYC-exempt contract whitelist
_subIssuerMintedmapping(address => uint256)Net outstanding tokens per sub-issuer
_subIssuerCapmapping(address => uint256)Issuance cap per sub-issuer (0 = unlimited)

Issuance

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.
function batchIssue(
    address[] calldata recipients,
    uint256[] calldata amounts,
    bytes32 reason
) external onlyRole(SUB_ISSUER_ROLE) whenNotPaused nonReentrant
    returns (uint256 processedCount)
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.

Redemption

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.
function redeemFrom(address account, uint256 amount, bytes32 reason)
    external onlyRole(SUB_ISSUER_ROLE) whenNotPaused nonReentrant
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.

Transfer Compliance

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.
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:
  1. Contract paused? -> PAUSED
  2. Sufficient balance? -> INSUFFICIENT_BALANCE
  3. Sufficient transferable balance (after frozen)? -> SENDER_TOKENS_FROZEN
  4. Sender verified? (skipped for authorized contracts) -> SENDER_NOT_ELIGIBLE
  5. Receiver verified? (skipped for authorized contracts) -> RECEIVER_NOT_ELIGIBLE
  6. Compliance module allows? -> COMPLIANCE_REJECTED

Token Freezing

function freezeTokens(address account, uint256 amount) external onlyRole(CONTROLLER_ROLE)
function unfreezeTokens(address account, uint256 amount) external onlyRole(CONTROLLER_ROLE)
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.

Force Transfer

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).

Stock Split

function stockSplit(
    uint256 numerator, uint256 denominator, address[] calldata holders
) external onlyRole(ISSUER_ROLE) whenNotPaused
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.

Configuration

FunctionRoleDescription
setIdentityRegistry(address)ISSUER_ROLEChange the identity registry contract
setComplianceModule(address)ISSUER_ROLESet or remove (pass address(0)) compliance module
setMetadataURI(string)ISSUER_ROLEUpdate off-chain metadata URI
setSubIssuerCap(address, uint256)ISSUER_ROLESet net outstanding cap for a sub-issuer (0 = unlimited)
addAuthorizedContract(address)ISSUER_ROLEWhitelist a contract for KYC-exempt transfers
removeAuthorizedContract(address)ISSUER_ROLERemove a contract from the whitelist
pause() / unpause()ISSUER_ROLEHalt or resume all token operations

View Functions

FunctionReturns
isin()ISIN identifier
metadataURI()Off-chain metadata URI
identityRegistry()Registry contract address
complianceModule()Compliance module address (zero if none)
frozenTokens(address)Frozen amount for an account
transferableBalance(address)Balance minus frozen tokens
splitRatio()Cumulative split numerator and denominator
isSubIssuer(address)Whether address holds SUB_ISSUER_ROLE
isController(address)Whether address holds CONTROLLER_ROLE
subIssuerMinted(address)Net outstanding tokens for a sub-issuer
subIssuerCap(address)Cap for a sub-issuer (0 = unlimited)
subIssuerOutstanding(address)Both minted and cap in one call
isAuthorizedContract(address)Whether a contract is whitelisted

Events

EventEmitted by
Issued(to, amount, reason, subIssuer)issue, batchIssue
Redeemed(from, amount, reason, redeemer)redeem, redeemFrom
ForceTransfer(from, to, amount, reason, controller)forceTransfer
TokensFrozen(account, amount)freezeTokens
TokensUnfrozen(account, amount)unfreezeTokens
StockSplit(numerator, denominator, newTotalSupply)stockSplit
BatchIssueCompleted(processed, total, reason)batchIssue
IdentityRegistryUpdated(old, new)setIdentityRegistry
ComplianceModuleUpdated(old, new)setComplianceModule
MetadataURIUpdated(old, new)setMetadataURI
SubIssuerUpdated(account, authorized)addSubIssuer, removeSubIssuer
ControllerUpdated(account, authorized)addController, removeController
AuthorizedContractAdded(contractAddr)addAuthorizedContract
AuthorizedContractRemoved(contractAddr)removeAuthorizedContract
SubIssuerCapUpdated(subIssuer, oldCap, newCap)setSubIssuerCap

IdentityRegistryUpgradeable

MiCA/eWpG-compliant KYC/AML registry shared across all token contracts in an instance. See Customer Management for conceptual documentation.

Initialization

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.

Roles

RolePurpose
DEFAULT_ADMIN_ROLERole management, compliance module configuration, pause/unpause
KYC_PROVIDER_ROLEVerify, update identities and manage claims
REGISTER_OPERATOR_ROLERevoke identities
COMPLIANCE_ROLEFreeze/unfreeze addresses
LEGAL_OPERATOR_ROLEAuthorize upgrades

Identity Lifecycle

Identities move through a defined lifecycle: verification, optional updates, soft expiry (KYC refresh needed), hard expiry (transfers blocked), and revocation.
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.

Claims

Claims are typed attestations attached to an identity. They support the full range of MiCA reporting requirements.
function addClaim(
    address user,
    ClaimType claimType, // KYC, AML, ACCREDITATION, RESIDENCY, CITIZENSHIP, TAX_RESIDENCY, SANCTIONS_CHECK, PEP_CHECK
    bytes32 dataHash,    // Zero-knowledge hash of claim data
    uint64 expiry        // 0 for no expiry
) external onlyRole(KYC_PROVIDER_ROLE)
Claims can be revoked individually via revokeClaim(). The getClaims() view returns only active claims.

Transfer Compliance Check

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.

Address Freezing

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).

BasicComplianceModule

Stateless compliance module that enforces holding limits and lockup periods. Deployed per token contract, controlled by the token contract (no separate admin keys).

Constructor

constructor(
    address _tokenContract,     // Token that controls this module
    uint256 _defaultMaxHolding, // Global default max (0 = unlimited)
    uint256 _defaultMinHolding  // Global default min
)

Rules

The module checks three conditions on every canTransfer call:
  1. Sender lockup. If the sender’s lockup has not expired, the transfer is rejected with SENDER_LOCKED.
  2. 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.
  3. 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.

Configuration (called by token contract)

FunctionDescription
setDefaultLimits(uint256 max, uint256 min)Update global default limits
setUserLimits(address, uint256 max, uint256 min)Override limits for a specific user
setLockup(address, uint256 until)Set lockup expiry timestamp (0 to clear)

View Functions

FunctionReturns
getUserLimits(address)Effective max, effective min, and whether user is locked
canTransfer(from, to, amount, fromBalance, toBalance)Compliance check result