Solutions · Exchanges and brokers
Deposits and withdrawals for every account holder
For exchanges, brokers, neobanks and wallet apps that hold balances for their users. Each user gets permanent deposit addresses. Your ledger is the record of who owns what. Valtu moves the coins: deposits in, sweeps to treasury, withdrawals out under your rules.
depositId.The custody model
Coins are pooled: a user's deposit is swept away from their address, and their withdrawal is paid from a shared hot wallet. That's normal for exchanges, and it's why your ledger, not the wallet balances, says what each user owns. Keep these in sync:
| Event | Your ledger |
|---|---|
deposit.confirmed | Credit the user (idempotent on depositId). |
| User requests withdrawal | Move the amount to "pending withdrawal" before calling the API. |
transfer.confirmed | Settle the pending amount. |
transfer.failed · .rejected · .cancelled | Return the pending amount to the user. |
deposit.orphaned | Reverse the credit (rare: a chain reorganization). |
Run a daily proof: the sum of your users' balances should equal what your Valtu wallets hold, minus fees you paid and plus fees you charged.
Wallet layout
| Wallet | Type | Notes |
|---|---|---|
| One per account holder | user | externalId = your user id. Created on sign-up or when they first open Deposit. |
| Treasury | treasury | Most of the funds. Sends only to saved addresses, with two approvers. |
| Hot wallet | operational | Pays withdrawals. Holds a day or two of withdrawals, no more. |
| Gas wallet | operational | Pays network fees for sweeps and token withdrawals. |
API keys
- Deposit service: role
operate, scoped to user wallets only. It can create user wallets and read their deposits, and nothing else. - Withdrawal service: role
operate, scoped to the hot wallet. - Reporting: role
read.
Each key with its own IP allowlist. If one leaks, it can't touch the others' wallets.
Withdrawals
export async function withdraw(userId: string, req: { asset: string; amount: string; to: string }) {
const id = await db.withdrawals.reserve(userId, req); // debits available, credits pending, returns your id
try {
const t = await valtu.transfers.create({
walletId: HOT_WALLET_ID, asset: req.asset, to: req.to,
amount: await valtu.assets.toBaseUnits(req.asset, req.amount),
externalId: id, category: "user-withdrawal",
});
await db.withdrawals.attach(id, t.id, t.status);
} catch (e) {
if (e instanceof ValtuError && e.status < 500) await db.withdrawals.release(id, e.code); // refused: give it back
else throw e; // unknown: retry later with the same id
}
}Suggested policies for the hot wallet:
| Rule | Effect |
|---|---|
withdrawal.value_usd <= 2000 && wallet.outflow_1h <= 50000 | Skip approval |
withdrawal.value_usd > 2000 | Require approval (Operations, 1) |
workspace.outflow_24h > 250000 | Require approval (Finance, 2) |
The velocity rules matter most: if a withdrawal service is ever compromised, a cap on hourly and daily outflow limits the damage to what you set.
Keeping the hot wallet funded
When the hot wallet runs low, a withdrawal fails with INSUFFICIENT, and waiting withdrawals are cancelled after 30 minutes. Set an alert on the hot wallet's balance, and refill it from treasury in the dashboard (Transfers → Send), which goes through the treasury's approvals.
Before going live
- Ledger credits keyed on
depositId; withdrawals reserved before the API call - Separate, scoped keys for deposits and withdrawals
- Velocity rules on the hot wallet; treasury sends to saved addresses only
- Daily reconciliation of user balances against wallet holdings
- Alerts for hot-wallet balance, low gas and failed transfers
- Customer screening and sanctions checks on withdrawal addresses