Skip to main content
This page covers how to deploy, configure, and integrate with the StockCustody contract. It assumes you have deployed the Stock Token suite and have a functioning identity registry and at least one StockToken instance.

Prerequisites

Before deploying StockCustody, the following must be in place:
  1. IdentityRegistryUpgradeable deployed and configured with KYC providers.
  2. At least one StockToken deployed with the identity registry address.
  3. USDC (or chosen settlement stablecoin) available on the target chain.
  4. A LiquidationRouter deployed, or a placeholder address if liquidation is not yet needed.

Compiler Configuration

The batchSettleTrades function takes 9 calldata array parameters plus batchId, which exceeds the EVM’s 16-slot stack limit in the legacy compilation pipeline. The Solidity compiler must use viaIR: true.
// hardhat.config.js
module.exports = {
  solidity: {
    version: "0.8.24",
    settings: {
      optimizer: { enabled: true, runs: 200 },
      viaIR: true
    }
  }
};
Without this setting, compilation will fail with a stack-too-deep error.

Deployment Sequence

1
Deploy StockCustody
2
Deploy the implementation contract, then deploy an ERC1967Proxy pointing to it and call initialize.
3
const { ethers, upgrades } = require("hardhat");

const StockCustody = await ethers.getContractFactory("StockCustody");

const custody = await upgrades.deployProxy(
  StockCustody,
  [
    "0xADMIN_ADDRESS",            // admin (multi-sig recommended)
    "0xOPERATOR_ADDRESS",         // settlement operator (or 0x0 to default to admin)
    "0xLIQUIDATION_ROUTER",       // router contract address
    "0xUSDC_ADDRESS"              // USDC token address
  ],
  { kind: "uups" }
);

await custody.deployed();
4
ParameterTypeConstraints_adminaddressCannot be zero_operatoraddressZero defaults to admin_liquidationRouteraddressCannot be zero_usdcaddressCannot be zero
5
Register Supported Tokens
6
Register each token the orderbook will trade. Set complianceChecked to true for security tokens and false for standard ERC-20s.
7
// Register stock token (compliance-checked)
await custody.addSupportedToken("0xSTOCK_TOKEN", true);

// Register USDC (standard ERC-20)
await custody.addSupportedToken("0xUSDC_ADDRESS", false);
8
Authorize Custody on StockToken
9
The custody contract must be registered as an authorized contract on each StockToken. Without this, all deposits of compliance-checked tokens will fail because the custody contract address cannot pass KYC checks.
10
const stockToken = await ethers.getContractAt("StockToken", "0xSTOCK_TOKEN");
await stockToken.addAuthorizedContract(custody.address);
11
This step is mandatory. It is the most common cause of failed deployments.

Deposit and Withdrawal

Users must deposit tokens into custody before they can place orders on the orderbook.
// Approve and deposit USDC
await usdc.approve(custody.address, ethers.utils.parseUnits("10000", 6));
await custody.deposit(usdc.address, ethers.utils.parseUnits("10000", 6));

// Approve and deposit stock tokens
await stockToken.approve(custody.address, ethers.utils.parseUnits("100", 18));
await custody.deposit(stockToken.address, ethers.utils.parseUnits("100", 18));
For compliance-checked tokens, pre-check transferability before submitting the deposit transaction:
const [canDo, code, reason] = await custody.checkTransferability(
  stockToken.address, userAddress, custody.address, amount
);

if (!canDo) {
  // Display `reason` to the user, handle `code` programmatically
}
Withdrawals work similarly. The user can only withdraw their available (unlocked) balance:
const available = await custody.getAvailableBalance(userAddress, usdc.address);
await custody.withdraw(usdc.address, available);

Trade Settlement Flow

The matching engine (operator) follows a lock-then-settle pattern.
1
Lock Balances
2
When orders match off-chain, the operator locks both counterparties’ balances.
3
const lockId = ethers.utils.id("lock-001");

// Lock maker's USDC (maker is buyer)
await custody.lockBalance(maker, usdc.address, quoteAmount, lockId);

// Lock taker's stock tokens (taker is seller)
await custody.lockBalance(taker, stockToken.address, baseAmount, lockId);
4
Settle Trade
5
Atomically transfer locked balances between the counterparties.
6
const settlementId = ethers.utils.id("trade-001");

await custody.settleTrade(
  settlementId,
  maker,
  taker,
  stockToken.address,  // base token
  usdc.address,         // quote token
  baseAmount,
  quoteAmount,
  true                  // makerIsBuyer
);
For high-throughput scenarios, use batchSettleTrades to settle multiple trades in one transaction. Size batches based on your target chain’s block gas limit. The gas guard stops processing if remaining gas drops below 80,000, so always check the BatchSettled event’s settledCount against totalSubmitted and retry any remaining settlements.

Liquidation Flows

Two paths exist for settling liquidated collateral received from a lending market.

Path A: Internal Buyer

The buyer already has USDC in custody. Tokens move between internal balances and USDC is transferred on-chain to the router.
await custody.settleLiquidationWithBuyer(
  liquidationId,
  buyerAddress,
  tokenAmount,
  usdcAmount
);
Partial fills are supported. The liquidation remains open until the full token amount is matched. Check getLiquidation(liquidationId) to see the remaining amount.

Path B: External Sale

The operator sells tokens through an external venue, then provides USDC proceeds.
// Operator approves USDC to custody
await usdc.approve(custody.address, usdcReceived);

await custody.settleLiquidation(
  liquidationId,
  usdcReceived,
  externalBuyerAddress  // receives the stock tokens
);
Both paths notify the lending market via ILendingMarket.receiveLiquidationProceeds. If the callback reverts, settlement still completes. Monitor LiquidationSettled and LiquidationSoldToBuyer events for reconciliation.

Priority Trade Flow

For liquidating tokens already held in a user’s custody balance (not received via the router):
// 1. Freeze user to prevent withdrawal front-running
await custody.freezeUser(liquidatedUser);

// 2. Lock the user's tokens
await custody.lockBalance(liquidatedUser, stockToken.address, amount, lockId);

// 3. Settle with a buyer
await custody.settlePriorityTrade(
  settlementId,
  liquidatedUser,
  buyer,
  stockToken.address,
  usdc.address,
  baseAmount,
  quoteAmount
);

// 4. Unfreeze the user
await custody.unfreezeUser(liquidatedUser);
On L1 Ethereum, front-running between these steps is possible. Deploy on an L2 or use a private mempool for liquidation transactions.

Stock Split Reconciliation

After a StockToken.stockSplit() executes, internal custody balances must be updated to match the new token supply.
// Get all users with custody balances (from your off-chain index)
const users = ["0xUser1", "0xUser2", "0xUser3"];

// 2:1 split
await custody.reconcileAfterSplit(stockToken.address, users, 2, 1);
Monitor the StockSplit event on StockToken to trigger reconciliation. The user list must be complete - any omitted user will have stale balances.

Listening to Events

Index these events for your backend, matching engine, and reporting systems:
// Custody operations
custody.on("Deposited", (user, token, amount, newBalance) => { /* ... */ });
custody.on("Withdrawn", (user, token, amount) => { /* ... */ });

// Trade lifecycle
custody.on("BalanceLocked", (user, token, amount, lockId) => { /* ... */ });
custody.on("TradeSettled", (settlementId, maker, taker, baseToken, quoteToken, baseAmount, quoteAmount) => { /* ... */ });
custody.on("BatchSettled", (batchId, settledCount, totalSubmitted) => { /* ... */ });

// Liquidation
custody.on("LiquidationReceived", (liquidationId, token, amount, debtOwed, borrower, lendingMarket) => { /* ... */ });
custody.on("LiquidationSettled", (liquidationId, usdcReceived, tokenRecipient) => { /* ... */ });
custody.on("LiquidationSoldToBuyer", (liquidationId, buyer, tokenAmount, usdcAmount, fullyFilled) => { /* ... */ });

// Emergency controls
custody.on("UserFrozen", (user, operator) => { /* ... */ });
custody.on("WithdrawalsPaused", (operator) => { /* ... */ });

// Split reconciliation
custody.on("BalanceReconciled", (user, token, oldBalance, newBalance) => { /* ... */ });

API Integration

If you are using Trusset’s API layer, custody balances and settlements are managed through the Stock Trading and Stock Trading Settlements API groups. The API handles deposit orchestration, settlement submission, and balance queries without direct contract interaction. For direct contract integration (self-hosted or custom deployment), use standard ethers.js or viem patterns with the contract ABIs included in the license package.