Skip to main content
This page covers operational patterns, security considerations, and common pitfalls when running StockCustody in production.

Key Management

Deploy the admin as a multi-sig. The admin key controls upgrades, token registration, USDC address changes, and router changes. A compromised admin key can upgrade the contract to an arbitrary implementation. Use a Gnosis Safe with a minimum 2-of-3 threshold. Consider adding a timelock between upgrade proposal and execution at the proxy level. Run the operator as a dedicated hot wallet with monitoring. The operator key can settle trades and move internal balances between users. A compromised operator cannot withdraw tokens directly, but can manipulate internal balances through fake settlements. Implement real-time monitoring on all settlement events and alert on unexpected balance movements. Separate admin and operator keys. Although the onlyOperator modifier permits admin access as a fallback, production deployments should use distinct keys for each role. This limits blast radius if either key is compromised and keeps audit trails unambiguous.

Operator Security

The operator’s freezeUser capability should be used judiciously. An attacker with operator access could freeze all users, effectively halting the entire market. Rate-limit freeze operations in your operator service and alert on bulk freezes. Settlement IDs are the primary deduplication mechanism. The operator service must guarantee unique settlement IDs. If the operator replays a settlement ID, the transaction reverts with SettlementAlreadyProcessed, which is safe but wastes gas. Use deterministic ID generation (e.g., hash of trade details) rather than sequential counters to avoid collisions across operator restarts.

Balance Integrity

The contract’s internal ledger must always be backed by actual ERC-20 balances held by the contract. This invariant holds as long as all entry points are used correctly, but there are edge cases to be aware of. Do not send tokens directly to the contract address. Tokens sent via transfer instead of deposit will increase the contract’s ERC-20 balance without crediting any internal balance. These tokens become permanently unrecoverable unless an upgrade adds a sweep function. Monitor the settleLiquidationWithBuyer flow. This function deducts from the buyer’s internal USDC balance and transfers actual USDC from the contract to the router. The invariant holds because the buyer deposited real USDC via deposit(). If any code path ever credits internal USDC without an actual deposit, the contract’s real balance could fall below the sum of internal balances. No such path exists in the current contract, but flag this for audit awareness during any upgrades that modify balance logic.

Withdrawal Protection Strategy

Two independent withdrawal protection layers serve different purposes: Per-user freeze (freezeUser) blocks a specific user’s withdrawals. Use this during liquidation to prevent front-running between the lock and settlement steps. The recommended flow is: freeze user, lock balance, settle, unfreeze. Keep the freeze duration as short as possible - a frozen user cannot withdraw any tokens, not just the tokens being liquidated. Global pause (pauseWithdrawals) acts as an emergency circuit breaker. Use this for contract-wide incidents: discovered vulnerabilities, oracle failures, or chain-level issues. The pause blocks all withdrawals for all users but does not affect trade settlements or liquidation flows. This means the operator can continue settling pending trades during a withdrawal pause, preventing a cascade of stale locks. Both mechanisms are independent of the token-level freezing (StockToken.freezeTokens) and registry-level freezing (IdentityRegistry.freezeAddress) in the Stock Token suite. A user can be unfrozen at the custody level but frozen at the token level, which would block their withdrawal at the canTransfer check.

Compliance Boundary

Trade settlements within custody do not validate on-chain compliance. This is by design - the off-chain matching engine validates compliance before matching orders, and settlements are internal ledger operations that do not trigger ERC-20 transfers. This means the matching engine carries the regulatory burden. Under MiCA, the beneficial ownership transfer at settlement time must be compliant. Your matching engine must verify both counterparties are eligible before creating a match. Specifically:
  • Both parties must have active (non-expired) KYC status in the identity registry.
  • Both parties must pass the compliance module’s canTransfer check for the relevant token.
  • Neither party should be frozen at the registry level.
If a user’s KYC expires between order placement and settlement, the matching engine should cancel the order rather than settling to a non-compliant party. Build a KYC status cache in your matching engine and subscribe to KYCStatusChanged events from the identity registry for real-time updates.

Stock Split Coordination

Stock splits require coordination between the StockToken contract and StockCustody. Pause the orderbook before executing a split. Cancel all open orders and ensure no settlements are pending. After the split, cached prices, order quantities, and collateral valuations in the matching engine are stale and must be recalculated. Reconcile immediately after the split. Monitor the StockSplit(numerator, denominator, newTotalSupply) event on StockToken. Once detected, call reconcileAfterSplit on StockCustody with the complete list of users holding that token in custody. Until reconciliation completes, internal balances will not match the contract’s actual ERC-20 balance, and withdrawals of the split token will produce incorrect amounts. Maintain a complete user list. The contract does not store a list of custody holders. Track this via Deposited and Withdrawn events or your backend database. If any user is omitted from the reconciliation call, their balance will be stale. Floor division creates dust. The split calculation uses (balance * numerator) / denominator with floor division. For non-evenly-divisible balances, some users may receive slightly less than the exact ratio. The dust stays unallocated in the contract’s ERC-20 balance. Handle these remainders off-chain if precision matters.

Liquidation Router Coordination

The liquidation router is a separate contract that bridges between lending markets and custody. It must approve the custody contract to pull collateral tokens when calling receiveLiquidatedCollateral. If the router’s approval is insufficient or revoked, collateral delivery will fail. When updating the router address via setLiquidationRouter, ensure the new router has approved the custody contract before switching. A misconfigured router blocks all new liquidation flows. Pending liquidations received from the old router can still be settled - the router address is only used for receiving collateral and forwarding USDC proceeds. The lending market callback (receiveLiquidationProceeds) is wrapped in a try/catch. If the lending market contract reverts, settlement still completes. Build reconciliation logic that monitors LiquidationSettled and LiquidationSoldToBuyer events and compares them against the lending market’s internal state.

Front-Running Mitigation

On L1 Ethereum, the priority trade flow (freeze, lock, settle, unfreeze) is vulnerable to front-running between steps. A user watching the mempool could withdraw tokens between the freeze transaction being submitted and being mined. Mitigate this by deploying on an L2 with a sequencer (where transaction ordering is deterministic) or by using a private mempool service (e.g., Flashbots Protect) for liquidation transactions. On L2s with sub-second block times, the window is negligible.

Batch Settlement Sizing

The batchSettleTrades gas guard stops processing at 80,000 remaining gas per iteration. A single trade settlement within a batch costs approximately 40,000-60,000 gas depending on storage slot warmth. On chains with higher block gas limits (L2s), you can fit larger batches. Size batches conservatively at first and monitor BatchSettled events. If settledCount is consistently less than totalSubmitted, your batches are too large for the available block gas. Reduce batch size or split into multiple transactions. Always check the return event and retry unsettled trades. The gas guard is a safety mechanism, not an error - it prevents the entire batch from reverting due to out-of-gas.

Upgrade Safety

The contract includes a uint256[50] private __gap storage reservation. When adding state variables in future upgrades, reduce the gap size accordingly. Never insert variables before existing state declarations. Before upgrading:
  1. Deploy the new implementation to a test environment and run your full test suite against the existing proxy state.
  2. Verify storage layout compatibility using OpenZeppelin’s upgrade safety tooling.
  3. Have the upgrade reviewed by your security auditor.
  4. Execute through the admin multi-sig with appropriate governance approval.
A bricked proxy from an incompatible storage layout permanently locks all custodied tokens. Storage validation before every upgrade is not optional.

Token Removal

Removing a token via removeSupportedToken blocks new deposits and trade settlements for that token. Users with existing balances can still withdraw. This is the correct behavior for decommissioning a token - it prevents new activity while allowing users to exit. Do not remove a token while there are pending (locked) balances or open liquidations for that token. Settlements of locked balances will fail because the _executeTransfer internal function does not check the supported list, but new lock operations will revert. Clear all pending activity before removing.

Monitoring and Alerting

At minimum, monitor these events in production:
EventAlert priorityAction
BatchSettled where settledCount < totalSubmittedHighRetry remaining settlements
LiquidationReceivedHighOperator must match or sell tokens
UserFrozenMediumTrack freeze duration, alert if extended
WithdrawalsPausedHighInvestigate root cause, communicate to users
BalanceReconciledMediumVerify split was applied correctly
AdminTransferInitiatedHighVerify legitimacy of admin change
OperatorUpdatedHighVerify new operator key is secured
LiquidationRouterUpdatedMediumVerify new router approvals
TokenSupported with supported=falseMediumVerify no pending activity for removed token
Track the pendingLiquidationCount state variable. If it grows without corresponding settlement events, liquidations are stalling and require operator intervention.