Custodian Asset Segregation System Development
Custodians are companies that professionally store crypto assets of clients. Key regulator and client requirement: different clients' assets must be strictly separated. "Common pool" unacceptable: if custodian suffers loss or bankruptcy, client must be able to uniquely identify their assets and get them back. This is segregation.
Regulatory Context
Asset segregation requirements determined by jurisdiction. MiCA (EU) requires separation of client assets from own. FCA (UK) — likewise. SEC for crypto custodians in US — still in development, but NYDFS BitLicense already contains segregation requirements.
Key requirement: ability to audit. Auditor anytime must be able to match on-chain addresses with client records and confirm that client A's assets not mixed with client B's.
Segregation Architectural Models
One Address Per Client (Full Segregation)
Each client gets unique on-chain address (or set — one per supported blockchain). Assets physically separated at blockchain level.
Advantages:
- Maximum transparency for client and auditor
- Simple proof of ownership
- Risk isolation: one client's problem doesn't affect others
Disadvantages:
- High operational costs with many clients (thousands of addresses, separate gas replenishments for each)
- Harder to optimize liquidity
Virtual Segregation with Common Pool
Assets stored on common addresses, but internal database keeps precise account of each client's share. Like bank account (you don't know in which physical storage your money lies).
Advantages:
- Low operational costs
- Easier liquidity management
Disadvantages:
- Regulatorily harder to justify as "segregation"
- On custodian bankruptcy — client assets can be contested by creditors
Hybrid Model
For large clients (institutions) — dedicated addresses. For retail — virtual segregation with option to transfer to dedicated address on request.
Detailed System Architecture
Address Management
Keys stored in HSM. Addresses generated deterministically via HD-derivation:
m/44'/60'/{clientId}'/0/0 → main client address
m/44'/60'/{clientId}'/0/1 → address for specific asset
m/44'/60'/{clientId}'/1/0 → change address
Allows recovering all addresses from master seed without additional storage.
interface ClientWallet {
clientId: string;
blockchain: string;
address: string;
derivationPath: string;
createdAt: Date;
keyStorageLocation: 'hsm_slot_1' | 'hsm_slot_2'; // for geo-redundancy
}
class AddressManager {
async createClientAddress(
clientId: string,
blockchain: string
): Promise<ClientWallet> {
// Atomic operation: check address not created yet
return this.db.transaction(async (trx) => {
const existing = await trx('client_wallets')
.where({ clientId, blockchain })
.first();
if (existing) return existing;
const index = await this.getNextIndex(trx, blockchain);
const derivationPath = `m/44'/60'/${clientId}'/0/${index}`;
const address = await this.hsm.deriveAddress(derivationPath);
return trx('client_wallets').insert({
clientId,
blockchain,
address,
derivationPath,
createdAt: new Date(),
}).returning('*');
});
}
}
Accounting (Ledger)
Double-entry bookkeeping mandatory. Every asset movement must balance:
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
entry_type VARCHAR(32) NOT NULL, -- debit/credit
client_id UUID NOT NULL REFERENCES clients(id),
asset VARCHAR(64) NOT NULL, -- ETH, USDC, BTC
blockchain VARCHAR(32) NOT NULL,
amount NUMERIC(36, 18) NOT NULL CHECK (amount > 0),
balance_after NUMERIC(36, 18) NOT NULL,
reference_type VARCHAR(32), -- deposit, withdrawal, fee, transfer
reference_id UUID,
tx_hash VARCHAR(66),
block_number BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
created_by VARCHAR(64) NOT NULL, -- system, user_id
CONSTRAINT no_negative_balance CHECK (balance_after >= 0)
);
CREATE TABLE client_balances (
client_id UUID REFERENCES clients(id),
asset VARCHAR(64),
blockchain VARCHAR(32),
balance NUMERIC(36, 18) NOT NULL DEFAULT 0,
last_updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (client_id, asset, blockchain)
);
Reconciliation. Daily system must reconcile on-chain balances with ledger records:
async function reconcile(blockchain: string, asset: string): Promise<ReconciliationResult> {
const clientWallets = await db.clientWallets.findAll({ blockchain });
const results = await Promise.all(
clientWallets.map(async (wallet) => {
const onChainBalance = await getOnChainBalance(wallet.address, asset);
const ledgerBalance = await db.clientBalances.getBalance(
wallet.clientId, asset, blockchain
);
const discrepancy = onChainBalance - ledgerBalance;
return {
clientId: wallet.clientId,
address: wallet.address,
onChainBalance,
ledgerBalance,
discrepancy,
status: Math.abs(discrepancy) < TOLERANCE ? 'ok' : 'mismatch',
};
})
);
const mismatches = results.filter(r => r.status === 'mismatch');
if (mismatches.length > 0) {
await this.alertService.sendAlert('RECONCILIATION_MISMATCH', mismatches);
}
return { results, mismatches, timestamp: new Date() };
}
Monitoring Incoming Deposits
System must track all incoming transactions to client addresses and auto-credit:
class DepositMonitor {
async watchAddress(address: string, clientId: string) {
// WebSocket subscription to new blocks
this.provider.on('block', async (blockNumber) => {
const block = await this.provider.getBlock(blockNumber, true);
for (const tx of block.transactions) {
if (tx.to?.toLowerCase() === address.toLowerCase()) {
await this.processDeposit({
clientId,
txHash: tx.hash,
amount: tx.value,
asset: 'ETH',
blockNumber,
});
}
}
});
// Also monitor ERC-20 Transfer events
this.monitorERC20Transfers(address, clientId);
}
private async processDeposit(deposit: DepositEvent) {
// Wait for confirmations (usually 12-20 for finality)
await this.waitForConfirmations(deposit.txHash, REQUIRED_CONFIRMATIONS);
await this.db.transaction(async (trx) => {
// Check idempotency
const existing = await trx('deposits').where({ txHash: deposit.txHash }).first();
if (existing) return;
// Record deposit
await trx('deposits').insert(deposit);
// Update ledger
await this.ledger.credit(trx, deposit.clientId, deposit.asset, deposit.amount);
});
}
}
Withdrawal (Withdrawal)
Withdrawal always requires approval workflow. After approval:
- Check client balance in ledger
- Reserve funds (debit pending)
- Sign transaction in HSM
- Broadcast on-chain
- Wait for confirmations
- Final ledger debit (or reversal on failure)
Idempotency. Each withdrawal request has unique idempotency key. Repeat request with same key returns existing status, doesn't create new.
Proof of Reserves
For public proof of solvency — Merkle tree based on client balances:
// Each client gets proof their balance included in overall tree
// Without revealing other clients' data
async function generateProofOfReserves(): Promise<ProofOfReserves> {
const balances = await db.getAllClientBalances();
// Build Merkle tree
const leaves = balances.map(b =>
keccak256(encode(['address', 'uint256'], [b.address, b.balance]))
);
const tree = new MerkleTree(leaves, keccak256, { sort: true });
// Publish root on-chain
await proofOfReservesContract.updateRoot(tree.getRoot());
return {
root: tree.getRoot(),
totalBalance: balances.reduce((sum, b) => sum + b.balance, 0n),
timestamp: Date.now(),
proofs: balances.map((b, i) => ({
clientId: b.clientId,
proof: tree.getProof(leaves[i]),
})),
};
}
Compliance and Audit
Audit trail. All operations with immutable history. Logs stored in append-only storage (AWS QLDB or PostgreSQL with audit triggers).
Reporting. Monthly reports for each client: fund movements, fees, current balances. Quarterly for auditors: reconciliation reports, proof of reserves.
KYT (Know Your Transaction). Integration with Chainalysis, Elliptic, or TRM Labs for checking incoming transactions against sanctioned addresses and mixers.
Stack
| Component | Technology |
|---|---|
| HSM | AWS CloudHSM or Thales |
| Database | PostgreSQL + AWS QLDB (audit log) |
| Blockchain monitoring | Alchemy/Infura + custom indexer |
| Reconciliation | Cron job + alerting |
| KYT | Chainalysis Reactor API |
| API | Node.js + TypeScript, REST + gRPC |
| Frontend | React (admin dashboard) |
Timeline
- Core ledger + address management: 6-8 weeks
- Deposit monitoring + withdrawal flow: 4-6 weeks
- Reconciliation + proof of reserves: 3-4 weeks
- KYT integration + compliance reporting: 3-4 weeks
- Security audit: mandatory, +4-8 weeks
Total: 5-6 months to production-ready system.







