Skip to main content

Managing customer crypto wallets

The core case. You run a fintech, your customers hold balances with you, and you want those balances to exist on chain the way the naira or dollar ones exist in your core banking system: an account number per customer, a balance you can quote, a statement you can produce, and a treasury you can actually operate.

This page is the whole job in the order you would do it. It ends with the part most integrations get wrong — the moment your ledger balance and the on-chain balance stop being the same number, on purpose.

What you are building

Six pieces across three columns: what you own, what ChainOS does, and what happens on chain — ending with a sweep that empties the customer's address while your ledger balance stays at 500.

Six pieces, and you own three of them. ChainOS issues and watches addresses, tells you what arrived, and consolidates. Your system decides what a customer is owed. Nothing in ChainOS knows that, and this page never pretends otherwise.

1. The organization

An organization is a tenant. It owns addresses, keys, webhooks, treasury configuration and a billing band, and nothing crosses between two of them — which is enforced per query rather than by a filter somebody could forget to apply.

curl -X POST $CLOUD/v1/organizations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Financial" }'

Most teams need exactly one. Create a second when the books must never be summed — a separate legal entity, a separate regulator, an acquisition you have not merged. Do not create one per environment: sandbox and live are the same organization, separated by the key prefix. See Environments.

Who gets which role

RoleMay do
ownerEverything, including the things that lose money: key rotation, identity-rotation approval, destructive wipe, members, billing
editorAddresses, transactions, webhooks, treasury policies. Not members, not billing, not keys
viewerRead only

Invite the people who will run this as editor. Keep owner to the smallest number of humans who can be reached at 3am, because two of the operations only they can perform — approving an identity rotation, and authorising a wipe — are the ones where a delay is much cheaper than a wrong answer.

Authorization reads the organization role, not the realm role

If you are building your own console against our IAM, read org_role from the token and never realm_access. A user who is Owner of one tenant and Viewer of another carries both in every token, because Keycloak does not scope realm_access to the active organization. See the token contract in the Ziklag IAM documentation.

2. The Edge

This is the step that makes the product non-custodial, and it is the only one that cannot be undone by a support ticket.

Generate the mnemonic offline, on a machine you trust, and back it up before you issue a single address. There is no reset flow, no escrow and no recovery. Read Mnemonic management end to end — it is the shortest path to understanding why everything else in this product is shaped the way it is.

Then run the container on your own infrastructure:

docker run -d --name chainos-edge \
-p 127.0.0.1:8787:8787 \
-v /run/secrets/chainos-mnemonic:/run/secrets/mnemonic:ro \
-v chainos_edge_data:/var/lib/chainos \
-e PASS_PHRASE_FILE=/run/secrets/mnemonic \
-e API_KEY="$CHAINOS_API_KEY" \
-e CHAINOS_CLOUD_URL=https://api.chainos.cloud \
ziklag/chainos-edge:1.0

GET /v1/settings/edge-setup generates this for your account with the right values filled in, which is less error-prone than copying it.

Three things to get right before you go further.

Check the derivation against a vector. The Edge's status page at http://localhost:8787/ shows what it derived. If you are rehearsing with the published BIP-39 test mnemonic, the Bitcoin address must be bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu and the Ethereum one 0x9858EfFD232B4033E47d90003D41EC34EcaEda94. Different values mean derivation has regressed and nothing after this matters.

Run more than one replica before you need to. Three goroutines share the leader lease, the outbox and the stream; the lease is what stops two replicas delivering the same webhook. High availability covers the topology. A single Edge is fine in sandbox and is a single point of failure for every withdrawal in production — deposits keep being detected without it, because Cloud holds the extended public keys, but nothing can be signed.

Mint a zkl_* key for your application. Your banking application must never hold the flk_* Ziklag credential. It holds a key your own Edge issued and talks only to localhost:8787:

curl -s -X POST http://localhost:8787/v1/edge/keys \
-H "Content-Type: application/json" -d '{ "label": "core-banking" }'

3. Gas tanks

A gas tank is one address per chain, derived at M/1/0 from your own mnemonic, that ChainOS may spend from without a human approving each transaction. It has exactly one production caller: topping up a deposit address so a token sweep can pay its own network fee.

You need one per chain you sweep tokens on. You do not need one to receive deposits, and you do not need one for native-coin sweeps.

curl -s $CLOUD/v1/edge/gas-tanks -H "Authorization: Bearer $TOKEN"

The tanks already exist — the Edge derived them at enrolment. What you do is fund them, in the chain's own native coin, as you would any hot wallet. A BTC tank cannot pay an Ethereum fee, so this is genuinely one balance per chain.

The autonomous spend cap defaults to zero

The Edge will not send a single wei of gas until you set a per-chain rolling cap. This is deliberate — it is the one place ChainOS spends your money with nobody asking — and it means token sweeps silently do nothing until you configure it:

EDGE_SPEND_CAP_POLYGON=50000000000000000000 # 50 POL per rolling window
EDGE_SPEND_CAP_ETH=500000000000000000 # 0.5 ETH
EDGE_SPEND_WINDOW=86400 # the window, in seconds

Size the cap at what a bad day costs, not at what a good day costs. It is a blast radius, not a budget.

Register each tank as a funding wallet once you start sweeping. That adds a low-balance alert and an estimatedSweepsRemaining figure, which is the number to put on an operations dashboard — "about 180 sweeps left" is actionable and "4.21 POL" is not.

4. The database you keep

This is the part ChainOS cannot do for you, and the shape of it decides whether your reconciliation works.

Four tables. The dialect does not matter; the columns do.

-- Your customers. You have this already.
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
external_ref TEXT UNIQUE NOT NULL,
status TEXT NOT NULL
);

-- One row per (customer, chain). Issued once and never rotated.
CREATE TABLE customer_addresses (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
chain TEXT NOT NULL,
address TEXT NOT NULL,
chainos_id TEXT NOT NULL, -- adr_…
derivation_path TEXT NOT NULL, -- absolute. This is your escape hatch
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, chain),
UNIQUE (chain, address)
);

-- Double entry. One movement writes two or more rows that sum to zero.
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
entry_group UUID NOT NULL, -- the movement; rows in a group sum to zero
account TEXT NOT NULL, -- 'customer:123' | 'house:treasury'
-- 'house:fees' | 'network:fees'
chain TEXT NOT NULL,
asset TEXT NOT NULL, -- 'USDT', or the native symbol
amount NUMERIC(78,0) NOT NULL, -- SIGNED, smallest unit. Never a float
decimals SMALLINT NOT NULL,
kind TEXT NOT NULL, -- deposit | withdrawal | sweep | fee | adjustment
chainos_txid TEXT, -- the chain transaction, where there is one
idempotency TEXT UNIQUE NOT NULL, -- see below
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Materialised, written in the same transaction as the entries.
CREATE TABLE ledger_balances (
account TEXT NOT NULL,
chain TEXT NOT NULL,
asset TEXT NOT NULL,
amount NUMERIC(78,0) NOT NULL DEFAULT 0,
PRIMARY KEY (account, chain, asset)
);

Five decisions in there are load-bearing.

NUMERIC(78,0), in the smallest unit, never a float. 10^18 does not survive a double and a USDT balance rounded in the fourth decimal place is a real loss to a real customer. This is the same type ChainOS stores on its side, for the same reason.

decimals is stored per row, from the event. USDT is six decimals on Ethereum and eighteen on BNB Smart Chain — same issuer, same ticker, different precision. Read the decimals on the deposit you are crediting, never a constant in your code. See the token catalogue.

idempotency is unique and derived, not generated. For a deposit, use deposit:{txid}:{outputIndex} — a redelivered webhook then collides with the row it already wrote instead of crediting the customer twice. Webhook delivery is at-least-once by contract; this column is what makes that safe. See Idempotency.

derivation_path is stored and is absolute. It is how you recover a customer's funds with your mnemonic and no ChainOS at all. A path relative to an extended public key you never receive would defeat its only purpose. Back this table up with the same seriousness as the mnemonic.

Balances are materialised and derivable. Write both in one transaction, then compare them on a schedule. A balance computed on demand is always self-consistent — it is whatever the sum says, including when a writer got it wrong. Two numbers arrived at independently disagree when something is broken, and that disagreement is the only thing that will ever tell you.

5. Issue an address per customer per chain

curl -s -X POST $EDGE/addresses \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Idempotency-Key: addr-cust_88213-eth" \
-H "Content-Type: application/json" \
-d '{ "chain": "eth", "userRef": "cust_88213", "tag": "deposit" }'

Send the Idempotency-Key. A retried create without one issues a second address to the same customer. You are now monitoring two, your customer has one, and both are real — so nothing will ever report this as an error.

userRef is your customer id, opaque to us, echoed on every webhook. It is what lets your handler credit the right account without a lookup table on the hot path.

Two facts that shape how many addresses you issue:

  • ETH, BSC, Polygon, Avalanche and Base share one address. One derivation path, five networks. Issue it once per customer and show it for all five — and remember that a deposit arriving on BSC to an address you issued for eth is still that customer's money. The webhook's chain field says which network it landed on.
  • Solana and Stellar need a live Edge to issue. Ed25519 admits no public-key-only derivation, so those two come from a pool the Edge pre-derives. An exhausted pool with no Edge connected fails with 423 EDGE_OFFLINE rather than issuing an address nobody holds a key for.

Addresses are not rotated. A fintech customer keeps the account number they were given; rotating produces deposits to an address your customer saved six months ago.

6. Record the flows

Two events per deposit, and they answer different questions.

deposit_detected one confirmation show as pending. DO NOT CREDIT
deposit_confirmed the threshold credit now
// One movement = one entry_group of rows summing to zero.
async function onDepositConfirmed(e: DepositConfirmed) {
const idem = `deposit:${e.data.txid}:${e.data.outputIndex ?? 0}`;
const amount = BigInt(e.data.amount); // smallest unit, from the event

await db.tx(async (t) => {
const group = randomUUID();

// Customer is owed more; the house holds more on chain. Two rows, sum zero.
const wrote = await t.query(
`INSERT INTO ledger_entries
(entry_group, account, chain, asset, amount, decimals, kind, chainos_txid, idempotency)
VALUES ($1, $2, $3, $4, $5::numeric, $7, 'deposit', $8, $9),
($1, 'house:onchain', $3, $4, $6::numeric, $7, 'deposit', $8, $9 || ':contra')
ON CONFLICT (idempotency) DO NOTHING
RETURNING id`,
[group, `customer:${e.data.userRef}`, e.data.chain,
// Keyed on the contract, never on the symbol. `tokenContract` is null for
// the chain's own currency and set for a token; `tokenSymbol` is filled in
// both cases, so `tokenSymbol ?? nativeOf(chain)` never reaches its
// fallback and a token that names itself BNB would be credited as BNB.
e.data.tokenContract ?? nativeOf(e.data.chain),
amount.toString(), (-amount).toString(), // both signs passed, never negated in SQL
e.data.decimals, e.data.txid, idem],
);
if (wrote.rowCount === 0) return; // already credited. Not an error

await bumpBalances(t, group);
});
}
Never credit on deposit_detected

A detected deposit sits in a mempool or an unconfirmed block and can still be reorganised out of existence on every chain here. Crediting it and debiting it again later is how a balance nobody can explain comes about. Show it as pending; credit on deposit_confirmed.

Withdrawals are the mirror image and are covered in Withdrawals. The one thing to carry over from here: debit the customer when you accept the instruction, not when the chain confirms it, or a customer can spend the same balance twice in the ninety seconds before a block.

7. Why your ledger balance stops matching the chain

This is the section to read twice.

Follow one customer through four days.

DayWhat happensOn chain, at their addressYour ledger, customer:88213
1Deposits 500 USDT500500
2Deposits 300 USDT800800
3A sweep runs0800
4Deposits 120 USDT120920

On day three nothing happened to the customer. Their money did not move in any sense they would recognise — you consolidated it into your treasury so that it could be spent, which is the entire reason sweeps exist. Their balance is 800 before the sweep and 800 after it.

So the on-chain balance of a customer's deposit address is not, and must never be, the number you show them. It is a stale, partial view: it excludes everything you have already swept and everything that is in flight. A system that reads a customer's balance from a node will show a customer 0 the morning after your first sweep and generate a support ticket for every single customer at once.

The number you show them comes from ledger_balances, which is the sum of the movements you recorded — deposits in, withdrawals out — and is unaffected by where you chose to keep the money.

The identity that has to hold

Because your ledger is now deliberately different from any single address, the thing you reconcile is an equation rather than a comparison. For one (chain, asset):

Σ customer balances
== Σ unswept deposit-address balances
+ the treasury wallet balance
− withdrawals broadcast and not yet confirmed
− sweeps broadcast and not yet confirmed
+ your own float, if you deposited any

Every term is observable. The left side is your ledger_balances; the right side comes from GET /v1/treasury/wallets/{id}/balances, GET /v1/balances/{chain}/{address} and your own record of what is in flight.

Run it daily, per chain, per asset, and alert on any drift at all. Not on drift above a threshold: a discrepancy of one unit means a write path exists that you do not know about, and its size tells you nothing about its seriousness. Reconciliation covers the mechanics.

Three things that legitimately break the identity

Know these before your first alert, because each looks like a bug and is not.

Someone sent an asset you do not credit. A token not in the catalogue is not detected and not credited — deliberately, because a chain carries thousands of tokens and many of them are built to resemble one that is not. The funds are at the address and are not in your ledger. See Custom tokens.

Gas top-ups moved native currency into a deposit address. Before a token sweep the tank sends the address enough native coin to pay its own fee. That is your money arriving at a customer's address and it belongs to house: and not to customer:. Attribute it from the tank, not from the address.

Retained gas float. retainGasFloat leaves a multiple of one transfer's fee behind after a native sweep so the next one does not need a top-up round trip. A native-coin address that never quite reaches zero after a sweep is that setting working.

8. Sweeps: getting the money somewhere you can use it

On every account-model chain — Ethereum, BSC, Polygon, Avalanche, Base, TRON, Solana, XRP, Stellar — balances at different addresses cannot be combined in one transaction. A 5,000 USDT withdrawal might need funds from forty addresses, each holding no native coin to pay for itself. Without consolidation the funds are there and are not spendable.

Three objects, in this order. Sweeps has the full detail; this is the sequence and the two settings that matter most.

A treasury wallet, which is where everything lands:

POST /v1/treasury/wallets
{ "chain": "polygon", "source": "derived", "label": "Main treasury" }

source: derived sends no address. ChainOS derives it at your reserved change path M/1/1, which means your Edge can already sign for it and means there is no address in this request body for anybody to change. That matters here more than anywhere: this is where every swept balance in your account ends up.

It is also the control that makes autonomous sweeping admissible at all. Your Edge re-derives that address from your own mnemonic and compares it with where the transaction is going; a mismatch is refused. Even a totally compromised Cloud could choose which addresses get emptied and when — an inconvenience and a fee — and could not choose where the funds go.

Solana and Stellar cannot be derived

Ed25519 again. Use source: external and nominate an address on those two, and understand that you are asserting it is correct — there is nothing for the Edge to re-derive, so an external treasury address must additionally be named in EDGE_SWEEP_TREASURY_ALLOWLIST on the Edge itself. That is deliberately more work than the derived case.

A funding wallet, if you sweep tokens — which names the gas tank from step 3 and takes no address either.

A policy per (chain, token):

POST /v1/treasury/sweeps/policies
{
"chain": "polygon", "token": "USDC", "enabled": true,
"triggerMode": "THRESHOLD_AND_SCHEDULE",
"minAmount": "50000000", "scheduleCron": "0 0 */4 * * *",
"treasuryWalletId": "…", "fundingWalletId": "…",
"gasFundingMode": "FUNDING_WALLET",
"maxFeeRatioBps": 300, "dustFloor": "1000000"
}

THRESHOLD_AND_SCHEDULE is the right default because real deposit traffic has two shapes: the threshold pulls a large deposit in within minutes, and the schedule batches the long tail of small balances into groups whose fee is worth paying. A cron frequent enough to do the first job does the second one badly.

Preview before you enable. Every time

POST /v1/treasury/sweeps/preview
{ "chain": "polygon", "token": "USDC", "dustFloor": "1000000", "maxFeeRatioBps": 300 }

It signs nothing, costs nothing and works on a policy that does not exist yet. The portal makes it mandatory before a policy can be enabled, and invalidates it when you change a setting, because enabling against a preview of different settings is exactly the mistake it exists to prevent.

The field to read is skipped. Nine hundred addresses below your floor is the line that tells you the floor is wrong — set too low it shows up as nine hundred tiny sweeps you are about to pay for, set too high as a total that never moves. dustFloor is the single most consequential number on the policy.

Bitcoin sweeps are planned but cannot yet be signed

A Bitcoin consolidation spends many inputs and needs one signature per input, which the Edge does not yet produce. A BTC sweep policy will plan jobs that never advance. Every other supported chain sweeps end to end. Plan your Bitcoin treasury operations by hand for now.

PARTIAL is a normal day

A 200-address job where 180 succeed and 20 run short of gas is not an incident. The failures retry on the next cycle; POST /v1/treasury/sweeps/jobs/{id}/retry re-runs the failed operations only, never the skipped ones — a skip was a decision the guards made, and retrying it reaches the same answer at the cost of another fee estimate. Change the policy instead.

Sweeps are never billed. Not as settled transactions, not as overage; the database refuses to record a sweep as billable rather than trusting the billing code to remember. Charging for consolidation would create an incentive to avoid it, which makes your treasury worse.

Do you need to sweep at all?

Sometimes not, and it is worth deciding rather than assuming. Funds at deposit addresses are safe, monitored, and spendable in place — a withdrawal can go straight from a customer's deposit address to their destination without passing through treasury. Sweeping earns its fee when your treasury operations need one balance to look at, when a downstream system expects a single source, or when you are paying for cold storage of one address. It does not earn it because a single balance feels tidier, and every sweep costs a fee and permanently links addresses that were previously unlinked.

9. Before you go live

Mnemonic backed up, tested by restoring it to an offline tool
At least two Edge replicas, leader lease verified under a kill test
EDGE_SPEND_CAP_* set for every chain you sweep tokens on
Gas tanks funded, low-balance alerts wired to somebody who is awake
Webhook handler idempotent — replay the same delivery twice and check nothing double-credits
Reconciliation job running daily and alerting on any drift
Sweep policies previewed, dustFloor justified per chain
derivation_path stored for every address and included in your backups
edge_offline and identity_rotation_detected paging a human, not an inbox

The last one is the security event. identity_rotation_detected means a different mnemonic is mounted on an Edge claiming to be yours, and it is delivered regardless of your subscription filter. Treat it as a page, not a notification. See Identity rotation.

Next