A comprehensive guide to building custodial asset management with selective disclosure using Midnight Network and zero-knowledge proofs
Table of Contents
- Introduction to Selective Disclosure
- Why Build Custodian Apps on Midnight?
- Understanding Selective Disclosure Architecture
- Prerequisites & Setup
- Project Structure
- Writing the Custodian Contract
- Implementing Selective Disclosure
- Frontend Integration
- Testing Your Contract
- Deployment Guide
- Advanced Patterns
- Compliance & Regulatory Considerations
- Next Steps & Resources
Introduction to Selective Disclosure
What is Selective Disclosure?
Selective Disclosure is a privacy-preserving technique that allows users to prove specific claims about their data without revealing the underlying information. Using zero-knowledge proofs, a user can demonstrate:
- They have sufficient balance without revealing the exact amount
- They are over 18 without revealing their birth date
- They are an accredited investor without revealing net worth
- They own specific assets without revealing their full portfolio
- They passed KYC without revealing personal documents
The Custodian Model
A custodian is an entity that holds and manages assets on behalf of clients. Traditional custodians include:
- Banks holding deposits
- Brokerages holding securities
- Crypto exchanges holding tokens
- Asset managers holding investment portfolios
The Privacy Problem
Traditional custodians face a dilemma:
- Full Transparency: Reveals sensitive client information
- Full Opacity: Provides no verifiability or auditability
Midnight's Solution: Selective disclosure enables custodians to prove claims about client assets while keeping sensitive details private.
Why Build Custodian Apps on Midnight?
Real-World Use Cases
1. Proof of Reserves
Exchanges can prove they hold sufficient assets to cover all customer deposits without revealing individual account balances.
2. Accredited Investor Verification
Investment platforms can verify investor accreditation status without collecting and storing sensitive financial documents.
3. Credit Scoring
Lenders can verify creditworthiness without seeing complete financial history.
4. Regulatory Compliance
Demonstrate compliance with regulations (AML/KYC) while maintaining client privacy.
5. Asset Verification
Prove ownership of specific asset classes without revealing entire portfolio.
Comparison: Traditional vs. Midnight Custodian
| Aspect | Traditional | Midnight |
|---|---|---|
| Balance Verification | Reveal exact amounts | Prove range/threshold |
| Identity Verification | Share documents | Prove attributes |
| Audit Trail | Full transaction history | Selective proof generation |
| Regulatory Reports | Detailed disclosures | Compliant proofs |
| Client Privacy | Minimal | Maximum |
Why Zero-Knowledge Proofs?
Traditional Approach:
User → "My balance is $50,000" → Verifier sees exact amount
ZK Approach:
User → "I can prove balance > $10,000" → Verifier learns ONLY that balance exceeds threshold
Understanding Selective Disclosure Architecture
System Architecture
+------------------------------------------------------------------+
| Client Application |
| +------------------+ +------------------+ +----------------+ |
| | Wallet Interface | | Proof Generator | | Claim Builder | |
| +------------------+ +------------------+ +----------------+ |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Midnight Network |
| +--------------------+ +--------------------+ +--------------+ |
| | Public Ledger | | Private State | | ZK Circuits | |
| | - Account exists | | - Actual balance | | - Proofs | |
| | - Proof validity | | - Personal data | | - Verifiers | |
| | - Custodian status | | - Asset details | | | |
| +--------------------+ +--------------------+ +--------------+ |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Verifier/Auditor |
| Can verify proofs without accessing private data |
+------------------------------------------------------------------+
Data Flow for Selective Disclosure
1. Client deposits assets with custodian
└─→ Balance stored in private state
2. Client requests disclosure proof
└─→ "Prove balance > $10,000"
3. ZK circuit generates proof
└─→ Uses private balance, outputs boolean
4. Verifier checks proof
└─→ Learns only: balance exceeds threshold
5. Private data never leaves client
└─→ Zero knowledge transfer
Key Components
1. Custodian Contract
Manages deposits, withdrawals, and account states.
2. Disclosure Circuits
ZK circuits that generate proofs about private data.
3. Verifier Functions
On-chain verification of disclosure proofs.
4. Credential Registry
Tracks verified attributes (KYC status, accreditation, etc.).
Prerequisites & Setup
Required Tools
# Check prerequisites
node -v # Required: v23+
npm -v # Required: v11+
docker -v # Required: Latest
Step 1: Install Compact Compiler
# Install Compact tools
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
# Install compiler version 0.27.0
compact update +0.27.0
# Verify installation
compact check
Step 2: Clone the Starter Template
# Clone repository
git clone https://github.com/MeshJS/midnight-starter-template.git
cd midnight-starter-template
# Install dependencies
npm install
Step 3: Create Custodian Contract Structure
# Create new contract directory
mkdir -p custodian-contract/src
mkdir -p custodian-contract/src/test
# Create package.json for custodian contract
cat > custodian-contract/package.json << 'EOF'
{
"name": "custodian-contract",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "compactc src/custodian.compact --output src/managed",
"test": "vitest run"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "^0.27.0"
},
"devDependencies": {
"vitest": "^1.0.0"
}
}
EOF
Project Structure
Directory Layout
midnight-starter-template/
├── custodian-contract/ # Smart contract code
│ ├── src/
│ │ ├── custodian.compact # Main contract logic
│ │ ├── witnesses.ts # Private state definitions
│ │ ├── managed/ # Compiled artifacts
│ │ └── test/
│ │ ├── custodian.test.ts # Unit tests
│ │ └── simulators/
│ │ └── simulator.ts # Test simulator
│ └── package.json
│
├── custodian-cli/ # CLI deployment tools
│ ├── src/
│ │ └── config.ts # Network configuration
│ └── package.json
│
├── frontend-vite-react/ # React frontend
│ ├── src/
│ │ ├── pages/
│ │ │ └── custodian/ # Custodian UI pages
│ │ └── modules/midnight/
│ │ └── custodian-sdk/ # Contract SDK
│ └── package.json
│
└── package.json # Root workspace config
Writing the Custodian Contract
The Complete Custodian Contract
Create custodian-contract/src/custodian.compact:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// ============================================
// CUSTODIAN CONTRACT WITH SELECTIVE DISCLOSURE
// ============================================
// Account status enum (as Uint<8>)
// 0 = Inactive
// 1 = Active
// 2 = Frozen
// 3 = Closed
// KYC Level enum (as Uint<8>)
// 0 = None
// 1 = Basic (email verified)
// 2 = Standard (ID verified)
// 3 = Enhanced (full verification)
// Accreditation Status (as Uint<8>)
// 0 = Not verified
// 1 = Accredited Investor
// 2 = Qualified Purchaser
// ============================================
// PUBLIC LEDGER STATE
// ============================================
// Total accounts registered
export ledger totalAccounts: Counter;
// Total assets under custody (aggregated, privacy-preserving)
export ledger totalAssetsUnderCustody: Counter;
// Account existence mapping (proves account exists without revealing balance)
export ledger accountExists: Map<Bytes<32>, Boolean>;
// Account status mapping
export ledger accountStatus: Map<Bytes<32>, Uint<8>>;
// KYC verification status (public proof that KYC was completed)
export ledger kycVerified: Map<Bytes<32>, Boolean>;
// KYC level (public)
export ledger kycLevel: Map<Bytes<32>, Uint<8>>;
// Accreditation status (public proof)
export ledger accreditationStatus: Map<Bytes<32>, Uint<8>>;
// Proof verification results (for auditors)
export ledger proofVerificationLog: Counter;
// Disclosure request nonces (prevent replay)
export ledger disclosureNonces: Map<Bytes<32>, Counter>;
// Custodian admin address
export ledger custodianAdmin: Bytes<32>;
// Contract initialized flag
export ledger initialized: Boolean;
// Minimum balance thresholds for different tiers
export ledger tierOneThreshold: Uint<64>; // e.g., 10,000
export ledger tierTwoThreshold: Uint<64>; // e.g., 100,000
export ledger tierThreeThreshold: Uint<64>; // e.g., 1,000,000
// ============================================
// INITIALIZATION
// ============================================
export circuit initialize(
adminAddress: Bytes<32>,
tier1: Uint<64>,
tier2: Uint<64>,
tier3: Uint<64>
): [] {
assert(!initialized, "Contract already initialized");
custodianAdmin = adminAddress;
tierOneThreshold = tier1;
tierTwoThreshold = tier2;
tierThreeThreshold = tier3;
initialized = true;
}
// ============================================
// ACCOUNT MANAGEMENT
// ============================================
// Register a new custodial account
// accountId is a hash of user's identity (privacy-preserving)
export circuit registerAccount(accountId: Bytes<32>): [] {
assert(initialized, "Contract not initialized");
assert(!accountExists[accountId], "Account already exists");
accountExists[accountId] = true;
accountStatus[accountId] = 1; // Active
kycVerified[accountId] = false;
kycLevel[accountId] = 0;
accreditationStatus[accountId] = 0;
totalAccounts.increment(1);
}
// Deposit assets (amount is added to private state, only aggregates are public)
export circuit deposit(
accountId: Bytes<32>,
amount: Uint<64>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accountStatus[accountId] == 1, "Account not active");
// Increment total custody (privacy: individual amounts hidden)
totalAssetsUnderCustody.increment(amount);
}
// Withdraw assets
export circuit withdraw(
accountId: Bytes<32>,
amount: Uint<64>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accountStatus[accountId] == 1, "Account not active");
// Note: In a full implementation, balance check would be in private state
// This decrements the public aggregate
// The actual balance verification happens via ZK proof
}
// ============================================
// KYC VERIFICATION (Admin Functions)
// ============================================
// Admin verifies KYC for an account
export circuit verifyKYC(
accountId: Bytes<32>,
level: Uint<8>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(level >= 1, "Invalid KYC level");
assert(level <= 3, "Invalid KYC level");
// In production: verify adminSig matches custodianAdmin
kycVerified[accountId] = true;
kycLevel[accountId] = level;
}
// Admin verifies accreditation status
export circuit verifyAccreditation(
accountId: Bytes<32>,
status: Uint<8>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(kycVerified[accountId], "KYC required first");
assert(status >= 1, "Invalid accreditation status");
assert(status <= 2, "Invalid accreditation status");
accreditationStatus[accountId] = status;
}
// ============================================
// SELECTIVE DISCLOSURE CIRCUITS
// ============================================
// Prove balance exceeds a threshold without revealing exact balance
// This circuit takes the actual balance as a private witness input
export circuit proveBalanceAboveThreshold(
accountId: Bytes<32>,
threshold: Uint<64>,
actualBalance: Uint<64>, // Private witness
nonce: Bytes<32> // Prevent replay
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accountStatus[accountId] == 1, "Account not active");
// The ZK magic: prove balance > threshold without revealing balance
assert(actualBalance >= threshold, "Balance below threshold");
// Log successful verification
proofVerificationLog.increment(1);
disclosureNonces[accountId].increment(1);
}
// Prove balance is within a range (e.g., $10k - $100k)
export circuit proveBalanceInRange(
accountId: Bytes<32>,
minBalance: Uint<64>,
maxBalance: Uint<64>,
actualBalance: Uint<64>, // Private witness
nonce: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(minBalance <= maxBalance, "Invalid range");
// Prove balance is within range
assert(actualBalance >= minBalance, "Balance below minimum");
assert(actualBalance <= maxBalance, "Balance above maximum");
proofVerificationLog.increment(1);
disclosureNonces[accountId].increment(1);
}
// Prove account tier (based on balance thresholds)
// Returns tier without revealing exact balance
export circuit proveAccountTier(
accountId: Bytes<32>,
actualBalance: Uint<64>, // Private witness
claimedTier: Uint<8>,
nonce: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(claimedTier >= 1, "Invalid tier");
assert(claimedTier <= 3, "Invalid tier");
// Verify claimed tier matches actual balance
if (claimedTier == 1) {
assert(actualBalance >= tierOneThreshold, "Insufficient balance for Tier 1");
}
if (claimedTier == 2) {
assert(actualBalance >= tierTwoThreshold, "Insufficient balance for Tier 2");
}
if (claimedTier == 3) {
assert(actualBalance >= tierThreeThreshold, "Insufficient balance for Tier 3");
}
proofVerificationLog.increment(1);
}
// Prove KYC status meets minimum level
export circuit proveKYCLevel(
accountId: Bytes<32>,
minimumLevel: Uint<8>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(kycVerified[accountId], "KYC not completed");
assert(kycLevel[accountId] >= minimumLevel, "KYC level insufficient");
proofVerificationLog.increment(1);
}
// Prove accredited investor status
export circuit proveAccreditedInvestor(accountId: Bytes<32>): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accreditationStatus[accountId] >= 1, "Not accredited");
proofVerificationLog.increment(1);
}
// Combined proof: KYC + Balance threshold (common for investments)
export circuit proveInvestmentEligibility(
accountId: Bytes<32>,
minKYCLevel: Uint<8>,
minBalance: Uint<64>,
actualBalance: Uint<64>, // Private witness
nonce: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accountStatus[accountId] == 1, "Account not active");
// Verify KYC
assert(kycVerified[accountId], "KYC required");
assert(kycLevel[accountId] >= minKYCLevel, "Insufficient KYC level");
// Verify balance (private)
assert(actualBalance >= minBalance, "Insufficient balance");
proofVerificationLog.increment(1);
}
// ============================================
// ADMIN FUNCTIONS
// ============================================
// Freeze an account (compliance action)
export circuit freezeAccount(
accountId: Bytes<32>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
accountStatus[accountId] = 2; // Frozen
}
// Unfreeze an account
export circuit unfreezeAccount(
accountId: Bytes<32>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
assert(accountStatus[accountId] == 2, "Account not frozen");
accountStatus[accountId] = 1; // Active
}
// Close an account
export circuit closeAccount(
accountId: Bytes<32>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(accountExists[accountId], "Account does not exist");
accountStatus[accountId] = 3; // Closed
}
// Update tier thresholds
export circuit updateTierThresholds(
tier1: Uint<64>,
tier2: Uint<64>,
tier3: Uint<64>,
adminSig: Bytes<32>
): [] {
assert(initialized, "Contract not initialized");
assert(tier1 < tier2, "Tier 1 must be less than Tier 2");
assert(tier2 < tier3, "Tier 2 must be less than Tier 3");
tierOneThreshold = tier1;
tierTwoThreshold = tier2;
tierThreeThreshold = tier3;
}
Implementing Selective Disclosure
Private State Definition
Create custodian-contract/src/witnesses.ts:
// ============================================
// PRIVATE STATE (WITNESSES) FOR CUSTODIAN
// ============================================
export type AssetHolding = {
assetType: string; // "TOKEN", "NFT", "FIAT"
assetId: string; // Token address or asset identifier
amount: bigint; // Actual amount held
acquisitionDate: number; // Unix timestamp
acquisitionPrice: bigint; // Original purchase price
};
export type PersonalData = {
firstName: string;
lastName: string;
dateOfBirth: number; // Unix timestamp
countryOfResidence: string;
taxId: string; // SSN, TIN, etc.
email: string;
phone: string;
};
export type KYCDocuments = {
idType: string; // "PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"
idNumber: string;
idExpiry: number;
proofOfAddressType: string;
verificationDate: number;
verifiedBy: string; // Verification provider
};
export type AccreditationData = {
verificationType: string; // "INCOME", "NET_WORTH", "PROFESSIONAL"
verificationDate: number;
expiryDate: number;
certifyingEntity: string;
supportingDocHash: string;
};
export type CustodianPrivateState = {
// Account identification
accountId: string; // Hashed account ID (public reference)
internalId: string; // Internal account number
// Financial data (PRIVATE - never disclosed directly)
totalBalance: bigint;
availableBalance: bigint;
lockedBalance: bigint;
holdings: AssetHolding[];
// Personal data (PRIVATE - only attributes disclosed via ZK)
personalData: PersonalData;
// KYC data (PRIVATE - only verification status disclosed)
kycDocuments: KYCDocuments | null;
// Accreditation (PRIVATE - only status disclosed)
accreditationData: AccreditationData | null;
// Transaction history (PRIVATE)
transactionCount: number;
lastActivityDate: number;
};
// Factory function to create initial private state
export const createPrivateState = (
accountId: string,
internalId: string
): CustodianPrivateState => {
return {
accountId,
internalId,
totalBalance: 0n,
availableBalance: 0n,
lockedBalance: 0n,
holdings: [],
personalData: {
firstName: '',
lastName: '',
dateOfBirth: 0,
countryOfResidence: '',
taxId: '',
email: '',
phone: '',
},
kycDocuments: null,
accreditationData: null,
transactionCount: 0,
lastActivityDate: 0,
};
};
// ============================================
// WITNESS FUNCTIONS FOR SELECTIVE DISCLOSURE
// ============================================
export const witnesses = {
// Witness for balance threshold proof
getBalanceForThresholdProof: (
state: CustodianPrivateState
): bigint => {
return state.totalBalance;
},
// Witness for range proof
getBalanceForRangeProof: (
state: CustodianPrivateState
): bigint => {
return state.availableBalance;
},
// Witness for tier proof
getTierWitness: (
state: CustodianPrivateState,
tierThresholds: { tier1: bigint; tier2: bigint; tier3: bigint }
): { balance: bigint; tier: number } => {
const balance = state.totalBalance;
let tier = 0;
if (balance >= tierThresholds.tier3) tier = 3;
else if (balance >= tierThresholds.tier2) tier = 2;
else if (balance >= tierThresholds.tier1) tier = 1;
return { balance, tier };
},
// Witness for age verification
getAgeWitness: (
state: CustodianPrivateState,
referenceDate: number
): { dateOfBirth: number; ageInYears: number } => {
const dob = state.personalData.dateOfBirth;
const ageMs = referenceDate - dob;
const ageYears = Math.floor(ageMs / (365.25 * 24 * 60 * 60 * 1000));
return { dateOfBirth: dob, ageInYears: ageYears };
},
// Witness for country verification
getResidencyWitness: (
state: CustodianPrivateState
): string => {
return state.personalData.countryOfResidence;
},
// Witness for investment eligibility
getInvestmentEligibilityWitness: (
state: CustodianPrivateState
): {
balance: bigint;
kycComplete: boolean;
isAccredited: boolean;
} => {
return {
balance: state.availableBalance,
kycComplete: state.kycDocuments !== null,
isAccredited: state.accreditationData !== null,
};
},
};
// ============================================
// DISCLOSURE PROOF BUILDERS
// ============================================
export type DisclosureType =
| 'BALANCE_THRESHOLD'
| 'BALANCE_RANGE'
| 'ACCOUNT_TIER'
| 'KYC_LEVEL'
| 'ACCREDITATION'
| 'AGE_VERIFICATION'
| 'RESIDENCY'
| 'INVESTMENT_ELIGIBILITY';
export type DisclosureRequest = {
type: DisclosureType;
accountId: string;
parameters: Record<string, unknown>;
nonce: string;
requestedBy: string;
requestedAt: number;
expiresAt: number;
};
export type DisclosureProof = {
type: DisclosureType;
accountId: string;
claim: string; // Human-readable claim
proofData: Uint8Array; // ZK proof bytes
publicInputs: unknown[]; // Public inputs for verification
generatedAt: number;
expiresAt: number;
};
// Build a disclosure proof from private state
export const buildDisclosureProof = async (
request: DisclosureRequest,
privateState: CustodianPrivateState
): Promise<DisclosureProof> => {
// This would integrate with the Midnight proof server
// to generate actual ZK proofs
const now = Date.now();
switch (request.type) {
case 'BALANCE_THRESHOLD':
const threshold = request.parameters.threshold as bigint;
const meetsThreshold = privateState.totalBalance >= threshold;
return {
type: 'BALANCE_THRESHOLD',
accountId: request.accountId,
claim: `Balance exceeds ${threshold}`,
proofData: new Uint8Array(), // Actual proof would go here
publicInputs: [request.accountId, threshold, meetsThreshold],
generatedAt: now,
expiresAt: request.expiresAt,
};
case 'ACCOUNT_TIER':
const { tier } = witnesses.getTierWitness(privateState, {
tier1: 10000n,
tier2: 100000n,
tier3: 1000000n,
});
return {
type: 'ACCOUNT_TIER',
accountId: request.accountId,
claim: `Account is Tier ${tier}`,
proofData: new Uint8Array(),
publicInputs: [request.accountId, tier],
generatedAt: now,
expiresAt: request.expiresAt,
};
// Add more cases for other disclosure types...
default:
throw new Error(`Unsupported disclosure type: ${request.type}`);
}
};
Contract Controller
Create frontend-vite-react/src/modules/midnight/custodian-sdk/api/contractController.ts:
import {
deployContract,
findDeployedContract,
type DeployedContract,
type ContractAddress,
} from '@midnight-ntwrk/midnight-js-contracts';
import * as Rx from 'rxjs';
// Import compiled contract
import { Custodian } from 'custodian-contract/src/managed/custodian';
import {
type CustodianPrivateState,
createPrivateState,
witnesses,
type DisclosureRequest,
type DisclosureProof,
buildDisclosureProof,
} from 'custodian-contract/src/witnesses';
export type CustodianProviders = {
publicDataProvider: PublicDataProvider;
privateStateProvider: PrivateStateProvider;
zkConfigProvider: ZkConfigProvider;
proofProvider: ProofProvider;
midnightProvider: MidnightProvider;
walletProvider: WalletProvider;
};
export type CustodianLedgerState = {
totalAccounts: bigint;
totalAssetsUnderCustody: bigint;
initialized: boolean;
tierOneThreshold: bigint;
tierTwoThreshold: bigint;
tierThreeThreshold: bigint;
};
export type AccountInfo = {
accountId: string;
exists: boolean;
status: number;
kycVerified: boolean;
kycLevel: number;
accreditationStatus: number;
};
export type DerivedState = {
ledger: CustodianLedgerState;
accounts: Map<string, AccountInfo>;
privateState: CustodianPrivateState | null;
pendingDisclosures: DisclosureRequest[];
completedProofs: DisclosureProof[];
};
export interface CustodianControllerInterface {
readonly deployedContractAddress: ContractAddress;
readonly state$: Rx.Observable<DerivedState>;
// Account management
registerAccount(accountId: string): Promise<void>;
deposit(accountId: string, amount: bigint): Promise<void>;
withdraw(accountId: string, amount: bigint): Promise<void>;
// KYC/Accreditation
verifyKYC(accountId: string, level: number): Promise<void>;
verifyAccreditation(accountId: string, status: number): Promise<void>;
// Selective disclosure
requestDisclosure(request: DisclosureRequest): Promise<void>;
generateProof(request: DisclosureRequest): Promise<DisclosureProof>;
// Verification circuits
proveBalanceAboveThreshold(
accountId: string,
threshold: bigint
): Promise<void>;
proveBalanceInRange(
accountId: string,
min: bigint,
max: bigint
): Promise<void>;
proveAccountTier(
accountId: string,
tier: number
): Promise<void>;
proveInvestmentEligibility(
accountId: string,
minKYC: number,
minBalance: bigint
): Promise<void>;
}
export class CustodianController implements CustodianControllerInterface {
readonly deployedContractAddress: ContractAddress;
readonly state$: Rx.Observable<DerivedState>;
private readonly deployedContract: DeployedContract<Custodian>;
private readonly providers: CustodianProviders;
private privateState: CustodianPrivateState | null = null;
private pendingDisclosures: DisclosureRequest[] = [];
private completedProofs: DisclosureProof[] = [];
private constructor(
deployedContract: DeployedContract<Custodian>,
providers: CustodianProviders
) {
this.deployedContract = deployedContract;
this.deployedContractAddress = deployedContract.deployTxData.public.contractAddress;
this.providers = providers;
// Set up reactive state
this.state$ = this.createStateObservable();
}
// ============================================
// FACTORY METHODS
// ============================================
static async deploy(
providers: CustodianProviders,
adminAddress: string,
tiers: { tier1: bigint; tier2: bigint; tier3: bigint }
): Promise<CustodianController> {
const deployedContract = await deployContract(providers, {
contract: Custodian,
initialPrivateState: createPrivateState('', ''),
});
const controller = new CustodianController(deployedContract, providers);
// Initialize contract
await controller.deployedContract.callTx.initialize(
stringToBytes32(adminAddress),
tiers.tier1,
tiers.tier2,
tiers.tier3
);
return controller;
}
static async join(
contractAddress: ContractAddress,
providers: CustodianProviders
): Promise<CustodianController> {
const deployedContract = await findDeployedContract(providers, {
contractAddress,
contract: Custodian,
});
return new CustodianController(deployedContract, providers);
}
// ============================================
// ACCOUNT MANAGEMENT
// ============================================
async registerAccount(accountId: string): Promise<void> {
const accountIdBytes = stringToBytes32(accountId);
await this.deployedContract.callTx.registerAccount(accountIdBytes);
// Initialize local private state
this.privateState = createPrivateState(accountId, generateInternalId());
}
async deposit(accountId: string, amount: bigint): Promise<void> {
const accountIdBytes = stringToBytes32(accountId);
await this.deployedContract.callTx.deposit(accountIdBytes, amount);
// Update local private state
if (this.privateState && this.privateState.accountId === accountId) {
this.privateState.totalBalance += amount;
this.privateState.availableBalance += amount;
this.privateState.transactionCount++;
this.privateState.lastActivityDate = Date.now();
}
}
async withdraw(accountId: string, amount: bigint): Promise<void> {
if (!this.privateState || this.privateState.accountId !== accountId) {
throw new Error('Not authorized for this account');
}
if (this.privateState.availableBalance < amount) {
throw new Error('Insufficient balance');
}
const accountIdBytes = stringToBytes32(accountId);
await this.deployedContract.callTx.withdraw(accountIdBytes, amount);
this.privateState.totalBalance -= amount;
this.privateState.availableBalance -= amount;
this.privateState.transactionCount++;
this.privateState.lastActivityDate = Date.now();
}
// ============================================
// KYC & ACCREDITATION
// ============================================
async verifyKYC(accountId: string, level: number): Promise<void> {
const accountIdBytes = stringToBytes32(accountId);
const adminSig = stringToBytes32('admin-signature'); // Simplified
await this.deployedContract.callTx.verifyKYC(
accountIdBytes,
BigInt(level),
adminSig
);
}
async verifyAccreditation(accountId: string, status: number): Promise<void> {
const accountIdBytes = stringToBytes32(accountId);
const adminSig = stringToBytes32('admin-signature');
await this.deployedContract.callTx.verifyAccreditation(
accountIdBytes,
BigInt(status),
adminSig
);
}
// ============================================
// SELECTIVE DISCLOSURE
// ============================================
async requestDisclosure(request: DisclosureRequest): Promise<void> {
this.pendingDisclosures.push(request);
}
async generateProof(request: DisclosureRequest): Promise<DisclosureProof> {
if (!this.privateState) {
throw new Error('No private state available');
}
const proof = await buildDisclosureProof(request, this.privateState);
this.completedProofs.push(proof);
// Remove from pending
this.pendingDisclosures = this.pendingDisclosures.filter(
(r) => r.nonce !== request.nonce
);
return proof;
}
async proveBalanceAboveThreshold(
accountId: string,
threshold: bigint
): Promise<void> {
if (!this.privateState || this.privateState.accountId !== accountId) {
throw new Error('Not authorized for this account');
}
const accountIdBytes = stringToBytes32(accountId);
const nonce = stringToBytes32(generateNonce());
await this.deployedContract.callTx.proveBalanceAboveThreshold(
accountIdBytes,
threshold,
this.privateState.totalBalance, // Private witness
nonce
);
}
async proveBalanceInRange(
accountId: string,
min: bigint,
max: bigint
): Promise<void> {
if (!this.privateState || this.privateState.accountId !== accountId) {
throw new Error('Not authorized for this account');
}
const accountIdBytes = stringToBytes32(accountId);
const nonce = stringToBytes32(generateNonce());
await this.deployedContract.callTx.proveBalanceInRange(
accountIdBytes,
min,
max,
this.privateState.availableBalance,
nonce
);
}
async proveAccountTier(accountId: string, tier: number): Promise<void> {
if (!this.privateState || this.privateState.accountId !== accountId) {
throw new Error('Not authorized for this account');
}
const accountIdBytes = stringToBytes32(accountId);
const nonce = stringToBytes32(generateNonce());
await this.deployedContract.callTx.proveAccountTier(
accountIdBytes,
this.privateState.totalBalance,
BigInt(tier),
nonce
);
}
async proveInvestmentEligibility(
accountId: string,
minKYC: number,
minBalance: bigint
): Promise<void> {
if (!this.privateState || this.privateState.accountId !== accountId) {
throw new Error('Not authorized for this account');
}
const accountIdBytes = stringToBytes32(accountId);
const nonce = stringToBytes32(generateNonce());
await this.deployedContract.callTx.proveInvestmentEligibility(
accountIdBytes,
BigInt(minKYC),
minBalance,
this.privateState.availableBalance,
nonce
);
}
// ============================================
// PRIVATE HELPERS
// ============================================
private createStateObservable(): Rx.Observable<DerivedState> {
return this.providers.publicDataProvider
.contractStateObservable(this.deployedContractAddress, { type: 'all' })
.pipe(
Rx.map((state) => {
const ledger = Custodian.ledger(state.data);
return {
ledger: {
totalAccounts: ledger.totalAccounts,
totalAssetsUnderCustody: ledger.totalAssetsUnderCustody,
initialized: ledger.initialized,
tierOneThreshold: ledger.tierOneThreshold,
tierTwoThreshold: ledger.tierTwoThreshold,
tierThreeThreshold: ledger.tierThreeThreshold,
},
accounts: new Map(),
privateState: this.privateState,
pendingDisclosures: this.pendingDisclosures,
completedProofs: this.completedProofs,
};
})
);
}
}
// ============================================
// UTILITY FUNCTIONS
// ============================================
function stringToBytes32(str: string): Uint8Array {
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
const result = new Uint8Array(32);
result.set(bytes.slice(0, 32));
return result;
}
function generateNonce(): string {
return crypto.randomUUID();
}
function generateInternalId(): string {
return `INT-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
Frontend Integration
Main Custodian Dashboard
Create frontend-vite-react/src/pages/custodian/index.tsx:
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Shield,
Eye,
EyeOff,
CheckCircle,
AlertTriangle,
Lock,
Unlock,
FileCheck,
DollarSign,
Users,
Activity,
} from 'lucide-react';
// Types
type AccountStatus = 'inactive' | 'active' | 'frozen' | 'closed';
type KYCLevel = 0 | 1 | 2 | 3;
type AccreditationStatus = 0 | 1 | 2;
type AccountInfo = {
accountId: string;
status: AccountStatus;
kycLevel: KYCLevel;
accreditationStatus: AccreditationStatus;
// Private (local only)
balance?: bigint;
availableBalance?: bigint;
};
type DisclosureProof = {
type: string;
claim: string;
verified: boolean;
timestamp: number;
};
// Mock data for demonstration
const mockAccount: AccountInfo = {
accountId: '0x1234...5678',
status: 'active',
kycLevel: 2,
accreditationStatus: 1,
balance: 150000n,
availableBalance: 145000n,
};
export const CustodianDashboard = () => {
const [account, setAccount] = useState<AccountInfo | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const [showBalance, setShowBalance] = useState(false);
const [proofs, setProofs] = useState<DisclosureProof[]>([]);
const [loading, setLoading] = useState(false);
// Disclosure form state
const [disclosureType, setDisclosureType] = useState('');
const [threshold, setThreshold] = useState('');
useEffect(() => {
// Load account data
setAccount(mockAccount);
}, []);
const handleGenerateProof = async (type: string) => {
setLoading(true);
// Simulate proof generation
await new Promise((resolve) => setTimeout(resolve, 2000));
const newProof: DisclosureProof = {
type,
claim: getClaimText(type),
verified: true,
timestamp: Date.now(),
};
setProofs((prev) => [newProof, ...prev]);
setLoading(false);
};
const getClaimText = (type: string): string => {
switch (type) {
case 'balance_threshold':
return `Balance exceeds $${threshold}`;
case 'tier':
return 'Account is Tier 2 or higher';
case 'kyc':
return 'KYC Level 2 verified';
case 'accredited':
return 'Accredited Investor status verified';
case 'investment_eligible':
return 'Eligible for qualified investments';
default:
return 'Claim verified';
}
};
const getStatusBadge = (status: AccountStatus) => {
const variants: Record<AccountStatus, { color: string; icon: React.ReactNode }> = {
active: { color: 'bg-green-500', icon: <CheckCircle className="w-3 h-3" /> },
inactive: { color: 'bg-gray-500', icon: <AlertTriangle className="w-3 h-3" /> },
frozen: { color: 'bg-blue-500', icon: <Lock className="w-3 h-3" /> },
closed: { color: 'bg-red-500', icon: <AlertTriangle className="w-3 h-3" /> },
};
return (
<Badge className={`${variants[status].color} text-white`}>
{variants[status].icon}
<span className="ml-1 capitalize">{status}</span>
</Badge>
);
};
const getKYCBadge = (level: KYCLevel) => {
const labels = ['None', 'Basic', 'Standard', 'Enhanced'];
const colors = ['bg-gray-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
return (
<Badge className={`${colors[level]} text-white`}>
<FileCheck className="w-3 h-3 mr-1" />
KYC: {labels[level]}
</Badge>
);
};
if (!account) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" />
</div>
);
}
return (
<div className="min-h-screen bg-background py-8 px-4">
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Shield className="w-8 h-8 text-primary" />
Custodian Dashboard
</h1>
<p className="text-muted-foreground mt-1">
Privacy-preserving asset custody with selective disclosure
</p>
</div>
<div className="flex gap-2">
{getStatusBadge(account.status)}
{getKYCBadge(account.kycLevel)}
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Balance</p>
<div className="flex items-center gap-2">
{showBalance ? (
<p className="text-2xl font-bold">
${account.balance?.toLocaleString()}
</p>
) : (
<p className="text-2xl font-bold">*****</p>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setShowBalance(!showBalance)}
>
{showBalance ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
</div>
<DollarSign className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Account Tier</p>
<p className="text-2xl font-bold">Tier 2</p>
</div>
<Activity className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Proofs Generated</p>
<p className="text-2xl font-bold">{proofs.length}</p>
</div>
<Shield className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Accreditation</p>
<p className="text-2xl font-bold">
{account.accreditationStatus === 1 ? 'Verified' : 'Pending'}
</p>
</div>
<Users className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
</div>
{/* Main Content Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="disclosure">Selective Disclosure</TabsTrigger>
<TabsTrigger value="proofs">Proof History</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>
Your custodial account details
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">Account ID</span>
<span className="font-mono">{account.accountId}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
{getStatusBadge(account.status)}
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">KYC Level</span>
{getKYCBadge(account.kycLevel)}
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Accreditation</span>
<Badge variant={account.accreditationStatus ? 'default' : 'secondary'}>
{account.accreditationStatus === 1
? 'Accredited Investor'
: account.accreditationStatus === 2
? 'Qualified Purchaser'
: 'Not Verified'}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Privacy Status</CardTitle>
<CardDescription>
What information is public vs. private
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-green-600">
<Lock className="w-4 h-4" />
<span>Exact balance (Private)</span>
</div>
<div className="flex items-center gap-2 text-green-600">
<Lock className="w-4 h-4" />
<span>Personal information (Private)</span>
</div>
<div className="flex items-center gap-2 text-green-600">
<Lock className="w-4 h-4" />
<span>Transaction history (Private)</span>
</div>
<div className="flex items-center gap-2 text-blue-600">
<Unlock className="w-4 h-4" />
<span>Account exists (Public)</span>
</div>
<div className="flex items-center gap-2 text-blue-600">
<Unlock className="w-4 h-4" />
<span>KYC verified status (Public)</span>
</div>
</CardContent>
</Card>
</div>
<Alert>
<Shield className="w-4 h-4" />
<AlertDescription>
Your sensitive financial data is encrypted and stored privately.
Only you can generate proofs about your data, and you control
exactly what information is disclosed.
</AlertDescription>
</Alert>
</TabsContent>
{/* Selective Disclosure Tab */}
<TabsContent value="disclosure" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generate Disclosure Proofs</CardTitle>
<CardDescription>
Prove claims about your account without revealing sensitive data
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Balance Threshold Proof */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Balance Threshold Proof</h3>
<p className="text-sm text-muted-foreground">
Prove your balance exceeds a specific amount
</p>
</div>
<DollarSign className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex gap-4">
<div className="flex-1">
<Label htmlFor="threshold">Threshold Amount ($)</Label>
<Input
id="threshold"
type="number"
placeholder="10000"
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
/>
</div>
<Button
className="self-end"
onClick={() => handleGenerateProof('balance_threshold')}
disabled={loading || !threshold}
>
{loading ? 'Generating...' : 'Generate Proof'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
This will prove: "Balance ≥ ${threshold || '0'}" without revealing exact amount
</p>
</div>
{/* Tier Proof */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Account Tier Proof</h3>
<p className="text-sm text-muted-foreground">
Prove your account tier without revealing balance
</p>
</div>
<Activity className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => handleGenerateProof('tier')}
disabled={loading}
>
Prove Tier 1+
</Button>
<Button
variant="outline"
onClick={() => handleGenerateProof('tier')}
disabled={loading}
>
Prove Tier 2+
</Button>
<Button
variant="outline"
onClick={() => handleGenerateProof('tier')}
disabled={loading}
>
Prove Tier 3
</Button>
</div>
</div>
{/* KYC Level Proof */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">KYC Verification Proof</h3>
<p className="text-sm text-muted-foreground">
Prove KYC completion without revealing documents
</p>
</div>
<FileCheck className="w-6 h-6 text-muted-foreground" />
</div>
<Button
onClick={() => handleGenerateProof('kyc')}
disabled={loading}
>
Prove KYC Level 2+
</Button>
</div>
{/* Accredited Investor Proof */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Accredited Investor Proof</h3>
<p className="text-sm text-muted-foreground">
Prove accreditation without revealing financials
</p>
</div>
<Users className="w-6 h-6 text-muted-foreground" />
</div>
<Button
onClick={() => handleGenerateProof('accredited')}
disabled={loading || account.accreditationStatus === 0}
>
{account.accreditationStatus === 0
? 'Accreditation Required'
: 'Prove Accredited Status'}
</Button>
</div>
{/* Combined Investment Eligibility */}
<div className="border rounded-lg p-4 space-y-4 bg-primary/5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Investment Eligibility Proof</h3>
<p className="text-sm text-muted-foreground">
Combined proof: KYC + Balance + Accreditation
</p>
</div>
<Shield className="w-6 h-6 text-primary" />
</div>
<Button
onClick={() => handleGenerateProof('investment_eligible')}
disabled={loading}
className="w-full"
>
Generate Investment Eligibility Proof
</Button>
<p className="text-xs text-muted-foreground">
Proves you meet all requirements for qualified investments
in a single proof, without revealing specific details.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Proof History Tab */}
<TabsContent value="proofs" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generated Proofs</CardTitle>
<CardDescription>
History of your disclosure proofs
</CardDescription>
</CardHeader>
<CardContent>
{proofs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Shield className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No proofs generated yet</p>
<p className="text-sm">
Generate proofs from the Selective Disclosure tab
</p>
</div>
) : (
<div className="space-y-3">
{proofs.map((proof, index) => (
<div
key={index}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
{proof.verified ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertTriangle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="font-medium">{proof.claim}</p>
<p className="text-sm text-muted-foreground">
{new Date(proof.timestamp).toLocaleString()}
</p>
</div>
</div>
<Badge variant={proof.verified ? 'default' : 'secondary'}>
{proof.verified ? 'Verified' : 'Pending'}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Privacy Settings</CardTitle>
<CardDescription>
Configure your disclosure preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Auto-expire proofs</p>
<p className="text-sm text-muted-foreground">
Proofs automatically expire after 24 hours
</p>
</div>
<Button variant="outline">Configure</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Require confirmation</p>
<p className="text-sm text-muted-foreground">
Confirm before generating any proof
</p>
</div>
<Button variant="outline">Configure</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Audit log</p>
<p className="text-sm text-muted-foreground">
Keep record of all proof requests
</p>
</div>
<Button variant="outline">View Log</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
};
export default CustodianDashboard;
Verifier Component
Create frontend-vite-react/src/pages/custodian/verifier.tsx:
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Shield,
CheckCircle,
XCircle,
Upload,
Search,
} from 'lucide-react';
type VerificationResult = {
valid: boolean;
claim: string;
accountId: string;
timestamp: number;
expiresAt: number;
};
export const ProofVerifier = () => {
const [proofInput, setProofInput] = useState('');
const [verifying, setVerifying] = useState(false);
const [result, setResult] = useState<VerificationResult | null>(null);
const handleVerify = async () => {
setVerifying(true);
// Simulate verification
await new Promise((resolve) => setTimeout(resolve, 1500));
// Mock result
setResult({
valid: true,
claim: 'Balance exceeds $10,000',
accountId: '0x1234...5678',
timestamp: Date.now() - 3600000,
expiresAt: Date.now() + 86400000,
});
setVerifying(false);
};
return (
<div className="min-h-screen bg-background py-8 px-4">
<div className="max-w-2xl mx-auto space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold flex items-center justify-center gap-2">
<Shield className="w-8 h-8 text-primary" />
Proof Verifier
</h1>
<p className="text-muted-foreground mt-2">
Verify selective disclosure proofs without accessing private data
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Verify a Proof</CardTitle>
<CardDescription>
Paste a proof or upload a proof file to verify its validity
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="proof">Proof Data</Label>
<Input
id="proof"
placeholder="Paste proof data here..."
value={proofInput}
onChange={(e) => setProofInput(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleVerify}
disabled={verifying || !proofInput}
className="flex-1"
>
{verifying ? (
<>Verifying...</>
) : (
<>
<Search className="w-4 h-4 mr-2" />
Verify Proof
</>
)}
</Button>
<Button variant="outline">
<Upload className="w-4 h-4 mr-2" />
Upload File
</Button>
</div>
</CardContent>
</Card>
{result && (
<Card className={result.valid ? 'border-green-500' : 'border-red-500'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.valid ? (
<>
<CheckCircle className="w-6 h-6 text-green-500" />
Proof Valid
</>
) : (
<>
<XCircle className="w-6 h-6 text-red-500" />
Proof Invalid
</>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Claim</p>
<p className="font-medium">{result.claim}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Account</p>
<p className="font-mono">{result.accountId}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Generated</p>
<p>{new Date(result.timestamp).toLocaleString()}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expires</p>
<p>{new Date(result.expiresAt).toLocaleString()}</p>
</div>
</div>
<Alert>
<Shield className="w-4 h-4" />
<AlertDescription>
This proof was verified using zero-knowledge cryptography.
The verifier learned ONLY that the claim is true, without
accessing any private data.
</AlertDescription>
</Alert>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>What Can Be Verified</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Badge>Balance</Badge>
<span className="text-sm">Exceeds threshold</span>
</div>
<div className="flex items-center gap-2">
<Badge>Tier</Badge>
<span className="text-sm">Account tier level</span>
</div>
<div className="flex items-center gap-2">
<Badge>KYC</Badge>
<span className="text-sm">Verification level</span>
</div>
<div className="flex items-center gap-2">
<Badge>Accreditation</Badge>
<span className="text-sm">Investor status</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
};
export default ProofVerifier;
Testing Your Contract
Test Suite
Create custodian-contract/src/test/custodian.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { CustodianSimulator } from './simulators/simulator';
describe('Custodian Contract with Selective Disclosure', () => {
let simulator: CustodianSimulator;
beforeEach(() => {
simulator = CustodianSimulator.deploy({
adminAddress: 'admin-001',
tier1: 10000n,
tier2: 100000n,
tier3: 1000000n,
});
});
describe('Account Management', () => {
it('registers a new account', () => {
simulator.registerAccount('user-001');
const ledger = simulator.getLedger();
expect(ledger.totalAccounts).toBe(1n);
expect(simulator.accountExists('user-001')).toBe(true);
});
it('prevents duplicate account registration', () => {
simulator.registerAccount('user-001');
expect(() => {
simulator.registerAccount('user-001');
}).toThrow('Account already exists');
});
it('handles deposits correctly', () => {
simulator.registerAccount('user-001');
simulator.deposit('user-001', 50000n);
const ledger = simulator.getLedger();
expect(ledger.totalAssetsUnderCustody).toBe(50000n);
});
});
describe('KYC Verification', () => {
it('verifies KYC for an account', () => {
simulator.registerAccount('user-001');
simulator.verifyKYC('user-001', 2);
expect(simulator.getKYCLevel('user-001')).toBe(2);
expect(simulator.isKYCVerified('user-001')).toBe(true);
});
it('requires valid KYC level', () => {
simulator.registerAccount('user-001');
expect(() => {
simulator.verifyKYC('user-001', 5);
}).toThrow('Invalid KYC level');
});
});
describe('Selective Disclosure', () => {
beforeEach(() => {
simulator.registerAccount('user-001');
simulator.deposit('user-001', 150000n);
simulator.verifyKYC('user-001', 2);
});
it('proves balance above threshold', () => {
// This should succeed - balance 150000 > 100000
expect(() => {
simulator.proveBalanceAboveThreshold('user-001', 100000n, 150000n);
}).not.toThrow();
});
it('fails proof when balance is insufficient', () => {
// This should fail - balance 150000 < 200000
expect(() => {
simulator.proveBalanceAboveThreshold('user-001', 200000n, 150000n);
}).toThrow('Balance below threshold');
});
it('proves balance in range', () => {
expect(() => {
simulator.proveBalanceInRange('user-001', 100000n, 200000n, 150000n);
}).not.toThrow();
});
it('fails range proof when outside range', () => {
expect(() => {
simulator.proveBalanceInRange('user-001', 200000n, 300000n, 150000n);
}).toThrow('Balance below minimum');
});
it('proves account tier', () => {
// Balance 150000 qualifies for Tier 2 (100000 threshold)
expect(() => {
simulator.proveAccountTier('user-001', 150000n, 2);
}).not.toThrow();
});
it('fails tier proof for higher tier', () => {
// Balance 150000 does not qualify for Tier 3 (1000000 threshold)
expect(() => {
simulator.proveAccountTier('user-001', 150000n, 3);
}).toThrow('Insufficient balance for Tier 3');
});
it('proves KYC level', () => {
expect(() => {
simulator.proveKYCLevel('user-001', 2);
}).not.toThrow();
});
it('proves investment eligibility', () => {
simulator.verifyAccreditation('user-001', 1);
expect(() => {
simulator.proveInvestmentEligibility('user-001', 2, 100000n, 150000n);
}).not.toThrow();
});
});
describe('Admin Functions', () => {
it('freezes an account', () => {
simulator.registerAccount('user-001');
simulator.freezeAccount('user-001');
expect(simulator.getAccountStatus('user-001')).toBe(2); // Frozen
});
it('unfreezes an account', () => {
simulator.registerAccount('user-001');
simulator.freezeAccount('user-001');
simulator.unfreezeAccount('user-001');
expect(simulator.getAccountStatus('user-001')).toBe(1); // Active
});
it('prevents operations on frozen accounts', () => {
simulator.registerAccount('user-001');
simulator.freezeAccount('user-001');
expect(() => {
simulator.deposit('user-001', 10000n);
}).toThrow('Account not active');
});
});
describe('Privacy Guarantees', () => {
it('does not expose actual balance in ledger', () => {
simulator.registerAccount('user-001');
simulator.deposit('user-001', 150000n);
const ledger = simulator.getLedger();
// Ledger should only show aggregate, not individual balance
expect(ledger.totalAssetsUnderCustody).toBe(150000n);
// Individual balance should not be accessible from ledger
});
it('increments proof counter without exposing proof details', () => {
simulator.registerAccount('user-001');
simulator.deposit('user-001', 150000n);
const before = simulator.getLedger().proofVerificationLog;
simulator.proveBalanceAboveThreshold('user-001', 100000n, 150000n);
const after = simulator.getLedger().proofVerificationLog;
expect(after).toBe(before + 1n);
});
});
});
Test Simulator
Create custodian-contract/src/test/simulators/simulator.ts:
type LedgerState = {
totalAccounts: bigint;
totalAssetsUnderCustody: bigint;
initialized: boolean;
tierOneThreshold: bigint;
tierTwoThreshold: bigint;
tierThreeThreshold: bigint;
proofVerificationLog: bigint;
};
type AccountState = {
exists: boolean;
status: number;
kycVerified: boolean;
kycLevel: number;
accreditationStatus: number;
};
type InitConfig = {
adminAddress: string;
tier1: bigint;
tier2: bigint;
tier3: bigint;
};
export class CustodianSimulator {
private ledger: LedgerState;
private accounts: Map<string, AccountState>;
private balances: Map<string, bigint>;
private constructor(config: InitConfig) {
this.ledger = {
totalAccounts: 0n,
totalAssetsUnderCustody: 0n,
initialized: true,
tierOneThreshold: config.tier1,
tierTwoThreshold: config.tier2,
tierThreeThreshold: config.tier3,
proofVerificationLog: 0n,
};
this.accounts = new Map();
this.balances = new Map();
}
static deploy(config: InitConfig): CustodianSimulator {
return new CustodianSimulator(config);
}
getLedger(): LedgerState {
return { ...this.ledger };
}
accountExists(accountId: string): boolean {
return this.accounts.has(accountId);
}
getAccountStatus(accountId: string): number {
return this.accounts.get(accountId)?.status ?? 0;
}
getKYCLevel(accountId: string): number {
return this.accounts.get(accountId)?.kycLevel ?? 0;
}
isKYCVerified(accountId: string): boolean {
return this.accounts.get(accountId)?.kycVerified ?? false;
}
registerAccount(accountId: string): void {
if (this.accounts.has(accountId)) {
throw new Error('Account already exists');
}
this.accounts.set(accountId, {
exists: true,
status: 1,
kycVerified: false,
kycLevel: 0,
accreditationStatus: 0,
});
this.balances.set(accountId, 0n);
this.ledger.totalAccounts += 1n;
}
deposit(accountId: string, amount: bigint): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (account.status !== 1) throw new Error('Account not active');
this.balances.set(accountId, (this.balances.get(accountId) ?? 0n) + amount);
this.ledger.totalAssetsUnderCustody += amount;
}
verifyKYC(accountId: string, level: number): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (level < 1 || level > 3) throw new Error('Invalid KYC level');
account.kycVerified = true;
account.kycLevel = level;
}
verifyAccreditation(accountId: string, status: number): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (!account.kycVerified) throw new Error('KYC required first');
account.accreditationStatus = status;
}
proveBalanceAboveThreshold(
accountId: string,
threshold: bigint,
actualBalance: bigint
): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (actualBalance < threshold) throw new Error('Balance below threshold');
this.ledger.proofVerificationLog += 1n;
}
proveBalanceInRange(
accountId: string,
min: bigint,
max: bigint,
actualBalance: bigint
): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (actualBalance < min) throw new Error('Balance below minimum');
if (actualBalance > max) throw new Error('Balance above maximum');
this.ledger.proofVerificationLog += 1n;
}
proveAccountTier(
accountId: string,
actualBalance: bigint,
claimedTier: number
): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (claimedTier === 1 && actualBalance < this.ledger.tierOneThreshold) {
throw new Error('Insufficient balance for Tier 1');
}
if (claimedTier === 2 && actualBalance < this.ledger.tierTwoThreshold) {
throw new Error('Insufficient balance for Tier 2');
}
if (claimedTier === 3 && actualBalance < this.ledger.tierThreeThreshold) {
throw new Error('Insufficient balance for Tier 3');
}
this.ledger.proofVerificationLog += 1n;
}
proveKYCLevel(accountId: string, minimumLevel: number): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (!account.kycVerified) throw new Error('KYC not completed');
if (account.kycLevel < minimumLevel) throw new Error('KYC level insufficient');
this.ledger.proofVerificationLog += 1n;
}
proveInvestmentEligibility(
accountId: string,
minKYC: number,
minBalance: bigint,
actualBalance: bigint
): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (!account.kycVerified) throw new Error('KYC required');
if (account.kycLevel < minKYC) throw new Error('Insufficient KYC level');
if (actualBalance < minBalance) throw new Error('Insufficient balance');
this.ledger.proofVerificationLog += 1n;
}
freezeAccount(accountId: string): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
account.status = 2;
}
unfreezeAccount(accountId: string): void {
const account = this.accounts.get(accountId);
if (!account) throw new Error('Account does not exist');
if (account.status !== 2) throw new Error('Account not frozen');
account.status = 1;
}
}
Running Tests
# Navigate to contract directory
cd custodian-contract
# Run tests
npm run test
# Run with coverage
npm run test -- --coverage
Deployment Guide
Local Development
# Terminal 1: Start local Midnight infrastructure
npm run setup-standalone
# Terminal 2: Start frontend
npm run dev:frontend
Preview Network Deployment
- Configure Environment
# custodian-cli/.env
MY_PREVIEW_MNEMONIC="your twelve word mnemonic phrase"
ADMIN_ADDRESS="your-admin-address"
- Deploy Contract
cd custodian-cli
npm run deploy
- Initialize Contract
// Initialize with tier thresholds
await contract.initialize(
adminAddress,
10000n, // Tier 1: $10,000
100000n, // Tier 2: $100,000
1000000n // Tier 3: $1,000,000
);
Deployment Checklist
- [ ] Contract compiled successfully
- [ ] All tests passing
- [ ] Environment variables configured
- [ ] Admin wallet funded with tSTAR
- [ ] Tier thresholds defined
- [ ] Contract deployed
- [ ] Contract initialized
- [ ] Frontend configured with contract address
Advanced Patterns
Pattern 1: Multi-Asset Custody
// Support multiple asset types
export ledger assetTypes: Map<Bytes<32>, Boolean>;
export ledger assetTotals: Map<Bytes<32>, Counter>;
export circuit depositAsset(
accountId: Bytes<32>,
assetId: Bytes<32>,
amount: Uint<64>
): [] {
assert(accountExists[accountId], "Account does not exist");
assert(assetTypes[assetId], "Asset type not supported");
assetTotals[assetId].increment(amount);
}
// Prove holdings in specific asset
export circuit proveAssetHolding(
accountId: Bytes<32>,
assetId: Bytes<32>,
actualHolding: Uint<64>, // Private witness
minHolding: Uint<64>
): [] {
assert(actualHolding >= minHolding, "Insufficient holding");
proofVerificationLog.increment(1);
}
Pattern 2: Time-Bound Proofs
export ledger proofValidityPeriod: Uint<64>; // In seconds
export circuit proveBalanceWithExpiry(
accountId: Bytes<32>,
threshold: Uint<64>,
actualBalance: Uint<64>,
currentTime: Uint<64>,
expiryTime: Uint<64>
): [] {
assert(expiryTime > currentTime, "Proof already expired");
assert(expiryTime <= currentTime + proofValidityPeriod, "Expiry too far");
assert(actualBalance >= threshold, "Balance below threshold");
proofVerificationLog.increment(1);
}
Pattern 3: Delegated Proofs
// Allow authorized parties to generate proofs
export ledger authorizedDelegates: Map<Bytes<32>, Map<Bytes<32>, Boolean>>;
export circuit authorizeDelegateForProofs(
accountId: Bytes<32>,
delegateId: Bytes<32>
): [] {
assert(accountExists[accountId], "Account does not exist");
// Caller must be account owner (verified via signature)
authorizedDelegates[accountId][delegateId] = true;
}
export circuit delegatedBalanceProof(
accountId: Bytes<32>,
delegateId: Bytes<32>,
threshold: Uint<64>,
actualBalance: Uint<64>
): [] {
assert(authorizedDelegates[accountId][delegateId], "Not authorized");
assert(actualBalance >= threshold, "Balance below threshold");
proofVerificationLog.increment(1);
}
Pattern 4: Compliance Reporting
// Generate aggregate compliance reports without individual disclosure
export ledger complianceReportCount: Counter;
export circuit generateComplianceReport(
reportType: Uint<8>,
aggregateData: Uint<64>, // Private aggregate
threshold: Uint<64>
): [] {
// Prove aggregate metrics meet compliance requirements
// without revealing individual account data
assert(aggregateData >= threshold, "Compliance threshold not met");
complianceReportCount.increment(1);
}
Pattern 5: Proof Revocation
export ledger revokedProofs: Map<Bytes<32>, Boolean>;
export circuit revokeProof(
proofId: Bytes<32>,
adminSig: Bytes<32>
): [] {
// Admin can revoke proofs if account status changes
revokedProofs[proofId] = true;
}
// Verifier checks revocation status
pure function isProofValid(proofId: Bytes<32>): Boolean {
return !revokedProofs[proofId];
}
Compliance & Regulatory Considerations
KYC/AML Integration
Selective disclosure enables compliance without data exposure:
| Requirement | Traditional Approach | Selective Disclosure |
|---|---|---|
| Identity Verification | Share ID documents | Prove "ID verified by provider X" |
| Address Verification | Share utility bills | Prove "Residency verified" |
| Source of Funds | Bank statements | Prove "Funds from verified source" |
| Sanctions Check | Full name/DOB | Prove "Cleared sanctions check" |
Regulatory Benefits
- GDPR Compliance: Minimize data collection/exposure
- Data Breach Mitigation: Less sensitive data to protect
- Right to be Forgotten: Easier to delete (less data stored)
- Audit Trails: Cryptographic proof of compliance
Auditor Access
Auditors can verify:
- Total assets under custody (aggregate)
- Number of KYC-verified accounts
- Compliance proof counts
- System health metrics
Without accessing:
- Individual balances
- Personal information
- Transaction details
Next Steps & Resources
Learning Path
- Beginner: Implement basic account management
- Intermediate: Add selective disclosure circuits
- Advanced: Multi-asset custody with compliance reporting
Use Case Extensions
- Decentralized Exchange: Prove sufficient balance for trades
- Lending Platform: Prove collateral without revealing portfolio
- Insurance: Prove coverage without revealing policy details
- Healthcare: Prove insurance status without medical records
Official Resources
- Midnight Documentation: docs.midnight.network
- Compact Language Guide: docs.midnight.network/compact
- Lace Wallet: lace.io
- Faucet: faucet.preview.midnight.network
Quick Reference
Compact Disclosure Patterns
// Threshold proof
export circuit proveThreshold(
actual: Uint<64>, // Private witness
threshold: Uint<64> // Public parameter
): [] {
assert(actual >= threshold, "Below threshold");
}
// Range proof
export circuit proveRange(
actual: Uint<64>,
min: Uint<64>,
max: Uint<64>
): [] {
assert(actual >= min, "Below minimum");
assert(actual <= max, "Above maximum");
}
// Set membership proof
export circuit proveMembership(
value: Uint<8>,
validSet: Vector<Uint<8>, 10>
): [] {
// Prove value is in validSet
}
Disclosure Types Summary
| Type | Reveals | Hides |
|---|---|---|
| Balance Threshold | "Balance > X" | Exact balance |
| Balance Range | "X < Balance < Y" | Exact balance |
| Account Tier | Tier level | Balance that determines tier |
| KYC Level | Verification level | Documents, personal data |
| Accreditation | Status | Financial details |
| Age Verification | "Age > X" | Birth date |
Conclusion
This tutorial demonstrated how to build a privacy-preserving custodian application with selective disclosure on Midnight. Key takeaways:
- Zero-Knowledge Proofs enable proving claims without revealing underlying data
- Selective Disclosure gives users control over what information they share
- Compliance is achievable while maximizing privacy
- Auditable systems don't require full transparency
The custodian pattern applies to:
- Financial services
- Healthcare records
- Identity management
- Credential verification
- Any scenario requiring verified claims with privacy
Build trust through cryptographic proof, not data exposure.
Built with the Midnight Starter Template
Top comments (0)