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.

DepositUser walletPermanent addresses on every network.
CreditYour ledgerCredited once per depositId.
CollectTreasurySweeps gather deposits.
FloatHot walletTopped up from treasury by your team.
WithdrawPayoutFrom the hot wallet, under your policies.

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:

EventYour ledger
deposit.confirmedCredit the user (idempotent on depositId).
User requests withdrawalMove the amount to "pending withdrawal" before calling the API.
transfer.confirmedSettle the pending amount.
transfer.failed · .rejected · .cancelledReturn the pending amount to the user.
deposit.orphanedReverse 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

WalletTypeNotes
One per account holderuserexternalId = your user id. Created on sign-up or when they first open Deposit.
TreasurytreasuryMost of the funds. Sends only to saved addresses, with two approvers.
Hot walletoperationalPays withdrawals. Holds a day or two of withdrawals, no more.
Gas walletoperationalPays 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:

RuleEffect
withdrawal.value_usd <= 2000 && wallet.outflow_1h <= 50000Skip approval
withdrawal.value_usd > 2000Require approval (Operations, 1)
workspace.outflow_24h > 250000Require 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