Migrating your addresses to ChainOS
You already run wallet infrastructure. You hold keys, you have issued addresses to your customers, and the mapping from address to customer is your business. This page is the project plan for bringing that onto ChainOS without a single deposit going undetected and without telling one customer their address has changed.
Migration is the reference: every endpoint, every format, every error. This is the order to do it in, the gate at the end of each stage, and what to do when one of them fails.
The shape of the project

The weeks are indicative; a five-thousand-address book with one mnemonic is a two-day job and a two-hundred-thousand-address book across six chains with three key sources is not. What is not negotiable is the order, and the parallel run in the middle.
Two facts that make this safe
Adopt, do not consolidate. Adoption means ChainOS starts monitoring addresses it did not derive. Nothing moves on chain, no customer is told anything, and no deposit to an old address is missed. Consolidation — deriving fresh addresses and moving funds to them — means telling your retail customers their deposit address changed, which they will resist and which guarantees a long tail of deposits to addresses you abandoned. Adopt unless you cannot.
Historical transactions never fire webhooks. At commit, ChainOS records the current block height of every chain against every address it adopts. Anything in an earlier block is recorded, queryable and never announced. This is the invariant with the highest cost of being wrong in the product: without it, your first day on ChainOS would deliver a year of already-settled deposits to your webhook, and the obvious thing to do with a confirmed deposit is credit it.
T−4 · Assess
What you are producing
A one-page answer to: which case are we, how many addresses, on which chains, and can we sign for them?
POST /v1/migration/detect-case
{ "mnemonics": 1, "hasRawKeys": false, "standardWallet": null,
"providerHoldsKeys": false, "addressCount": 5000, "chains": ["eth", "polygon"] }
It creates nothing. Run it as often as you like while you work out what you actually hold — and most teams do not know at the start, which is what it is for.
| Case | You have | The consequence |
|---|---|---|
| A | One phrase, standard derivation | Barely a migration. Point your Edge at the existing phrase and the addresses simply are ChainOS addresses |
| B | One phrase, a path you cannot name | The path is detected empirically from a known address |
| C | Several phrases | One becomes the Edge's primary; the rest register as key sources |
| D | Individual keys, no shared seed | Raw import. No discovery — you supply the list |
| E | A mixture | One project, several sources. This is the case the design was built around |
| F | A custodian holds your keys | Watch-only, plus consolidation if they will sign |
standardWallet: null means "unsure", and it is the honest answer for most people. It
routes to Case B, where the path is worked out from an address rather than assumed. It is
never read as "probably standard" — that guess produces an Edge deriving addresses that are
not yours and a book that appears to have vanished.
The Case F question, asked early
If a provider holds your keys, everything depends on one question: will they sign transactions you construct?
- Yes — adopt watch-only so monitoring starts now, then consolidate at your leisure: ChainOS builds the unsigned transactions, your provider signs, ChainOS broadcasts.
- No — watch-only, permanently. Deposits detected, confirmations tracked, webhooks fired,
balances queryable; withdrawals refused with
422 ADDRESS_WATCH_ONLY.
Watch-only is a supported configuration and not a degraded one. But ask the question in week one, in writing, because the answer changes the project and finding out in week four costs you a migration you had already started.
Do not proceed until you can state, in one sentence each: which case you are, how many addresses you expect on each chain, and which of them you will be able to sign for.
"We will work that out as we go" is how a book gets adopted with a wrong derivation path.
T−3 · Registers keys and settle the derivation path
If you do not know your path
chainos-edge detect-path --address=0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD
Run it on your own machine. It searches BIP-44, 49, 84 and 86 for Bitcoin and the standard and Ledger Live variants for EVM, and prints the extended public key to register. Nothing leaves the machine, because producing the key for a candidate path needs the seed — which is why this cannot be a cloud endpoint.
If you hold an extended public key rather than a phrase, POST /v1/migration/detect-path does
the same search server-side.
From an account-level key, Ledger Live's first address and the standard first address are the same path. One address cannot tell them apart and the answer says so rather than guessing.
It matters more than it sounds: if you do use Ledger Live, every address after your first hangs off a hardened account that key cannot reach. A scan would find exactly one address and you would reasonably conclude the rest of your book had vanished.
Registering the wallets you already had
Key material reaches your Edge by a command you run on your own machine and by no other route. There is no endpoint that accepts a recovery phrase or a private key, no column that could hold one, and no support channel for one.
A request that looks like key material is refused with KEY_MATERIAL_REJECTED before
anything reads it — not after it has been deserialised, logged and traced, which would put it
in four places that outlive the refusal. The check is deliberately over-eager: twelve
consecutive BIP-39 words in a label will be refused. That trade is the right way round and it
is not close.
Register each source, then check what Cloud thinks it can sign for:
GET /v1/migration/key-sources
Bitcoin, TRON and XRP register a key and no addresses, and withdrawals on those chains are refused once a key source covers them — the Edge carries no address codec for the three, so it cannot verify a destination by re-deriving it, and signing on a destination it cannot check is how funds go to the wrong place with a well-formed signature attached. Read that section of Migration if any of those three are in scope.
Every chain in scope has either a registered key source whose extended public key is confirmed against a known address, or an explicit, written decision that it is watch-only.
An unconfirmed path is the single most expensive mistake available in this project.
T−2 · Discover, then read the dry run
Do not hand-build an address list
Every address you type is one you can mistype, and the failure mode is silent: a missed address is not an error, it is a deposit nobody detects.
POST /v1/migration/projects/{projectId}/discover
{ "keySourceId": "…", "chain": "eth", "gapLimit": 20, "maxScan": 5000,
"probeTokens": ["USDT", "USDC"],
"userRefMappingCsv": "0,cust-1041\n1,cust-1042\n" }
Three parameters decide whether the scan finds your whole book.
userRefMappingCsv is the field that matters. Discovery finds addresses; only you know
which customer each belongs to. Two columns of derivation_index,user_ref, which you can
produce from your own database with one query — and which you cannot get wrong the way a
hand-built address list can. A duplicate index is refused rather than guessed at.
probeTokens must list the tokens you actually hold. An address that only ever held USDT
has no native transaction count on some chains. A scan that looked only at the native side
would call it unused and walk past your money.
gapLimit decides where the scan stops. Twenty consecutive unused indices is the
convention. If your issuance was sparse — you burned indices on customers who never funded —
raise it, and be aware the scan gets proportionally more expensive.
Read truncated separately from the count. A scan that stopped at the gap limit found the
end of your wallet. One that stopped at maxScan ran out of permission, and only the second
means addresses were probably missed.
Ed25519 admits no public-key-only child derivation, so no extended public key exists and there is nothing to scan. Supply an explicit address list for those two chains. The API says this rather than returning an empty result, because "no addresses found" reads as an empty wallet rather than as an impossible question.
Price it before you commit to it
GET /v1/migration/projects/{projectId}/capacity
Adopting ten thousand addresses is a billing event as much as a technical one. This is the projection against your committed band, not against any temporary capacity grant — a grant expires, and a projection leaning on one promises capacity that is taken away on a date you never agreed to.
Import, validate, and read byPhase first
POST /v1/migration/projects/{projectId}/imports
POST /v1/migration/imports/{batchId}/validate
GET /v1/migration/imports/{batchId}/dry-run
Validation runs seven phases in order, and the phase a row failed at is the diagnosis:
| Phase | A run of failures usually means |
|---|---|
parse | A stray character, or the wrong format |
schema | An unknown chain, or an index that is not a number |
chain_format | The address column is not the address column |
derivation | The wrong base path, or misaligned columns |
uniqueness | The file was already imported |
spendability | No key source named |
projection | — |
Four thousand rows failing chain_format is one problem, not four thousand. That is what
byPhase is for, and it is the first field to read.
Three outcomes are deliberately not errors: an address already in your account is skipped (so correcting twelve rows and re-uploading your whole export is safe), EIP-55 casing that does not match is a warning, and a row that could not be traced to your key is adopted watch-only rather than refused.
One is different: an address another ChainOS account already claims fails the whole batch. Only one claim can be right, getting it wrong routes somebody else's deposits to you, and a person reviews it. Neither side is ever told who the other is.
The dry run's address count matches your own database's count, per chain, to the row — or every difference is explained in writing.
The dry run carries a dryRunId and the commit must quote it. Re-validating mints a new one,
so a commit quoting a stale report is refused. Reviewing it is structural, not advised.
T−1 · Adopt, and run both systems
POST /v1/migration/imports/{batchId}/commit
{ "dryRunId": "…", "acceptBillingChange": true }
acceptBillingChange is required only when adoption takes you past your band, and it is asked
separately from the commit: pressing the button and agreeing to a larger invoice are different
questions, and only one of them can be answered by a click you were already making.
The commit refuses rather than adopting without a watermark. If a chain cannot be reached,
it fails with CHAIN_UNAVAILABLE and nothing is imported — waiting five minutes costs nothing
next to replaying your settled history.
Observe mode is the whole point of this stage
Adopted addresses are monitored, their deposits are recorded, and they are queryable through the ordinary endpoints. They fire no webhooks and accept no withdrawals until you cut over.
So you run your old system and this one against the same addresses, at the same time, on real traffic, and compare.
GET /v1/migration/projects/{projectId}/observe
GET /v1/migration/projects/{projectId}/reconciliation
depositsObserved is the number to reconcile. historicalRecorded counts transactions that
predate the import and stayed silent — it should be large, and it should stop growing.
The reconciliation CSV is what you open beside your own export: every address, its derivation path, your own reference for it, and whether anything can sign for it.
How long to run in parallel
Long enough to see your slowest real pattern at least twice. Practically:
| A busy exchange-facing book | 3–5 days |
| Retail deposits with a weekly rhythm | 2 weeks |
| Anything with month-end concentration | Through one month end |
The cost of a longer parallel run is a capacity bill and some operator attention. The cost of a short one is discovering a gap after webhooks started firing.
The two rehearsals to run before you cut over
Both in sandbox, against a sandbox project.
1. The watermark holds.
curl -X POST $CLOUD/v1/sandbox/migration/projects/$PROJECT/simulate-activity \
-H "Authorization: Bearer $FLK_TEST_KEY" -H "Content-Type: application/json" \
-d '{"addresses": 50, "historicalPerAddress": 4, "livePerAddress": 1}'
Then check your endpoint. It must have received the transactions above the watermark and none below it. This is the invariant that matters most in a migration; verify it rather than trusting it.
2. A conflict is handled by a person, not by your retry loop.
POST /v1/sandbox/migration/projects/{projectId}/simulate-conflict
Two customers who both restored the same published test mnemonic produce identical addresses, and so does one customer moving a seed between a sandbox and a live account. It happens more often than it sounds like it should. See the refusal once, in sandbox, so nobody meets it for the first time at 2am.
depositsObservedmatches your own system's count for the same window, exactly.- Every address in your own database appears in the reconciliation export.
- The watermark rehearsal delivered nothing historical.
- Your webhook handler is idempotent, verified by sending one delivery twice.
- Somebody who is not the person who ran the import has reviewed the reconciliation CSV.
The rollback window, while you still have it
POST /v1/migration/imports/{batchId}/rollback
Available for 72 hours, and only while no transaction has been recorded on any adopted address. The second condition is the real one: once a customer has sent money to an address, removing it would leave a confirmed deposit belonging to an address your account no longer has.
Historical transactions do not close the window — those are the ones the watermark already silenced. The validated rows are kept, so you can correct your file and import again without re-uploading what was already right.
Plan as though this window does not exist. On a live book with real traffic it usually closes within hours.
T · Cut over
POST /v1/migration/projects/{projectId}/activate
Webhooks start firing and withdrawals are accepted. Owner only, with a second factor, because it is the moment your production traffic starts depending on us.
Do it at the quietest hour of your week, with the person who built the integration available, and with the reverse already tested:
POST /v1/migration/projects/{projectId}/deactivate
which holds webhooks and withdrawals again. It is gated more lightly than the cutover it reverses, deliberately: if you have just cut over and found your system and ours disagreeing, you need this to work now, not after a second person approves it. Nothing is lost, and activating again later announces nothing that happened in between.
The first hour
| Watch | Healthy |
|---|---|
| Webhook delivery success rate | ~100%; investigate any dead letters immediately |
Deposits credited by your system vs depositsObserved | Equal |
| Withdrawal attempts | Succeeding, or refused with a code you recognise |
ADDRESS_WATCH_ONLY refusals | Only on addresses you know are watch-only |
| Your own duplicate-credit alarm | Silent |
Have the duplicate-credit alarm before you cut over, not after. The failure this whole design protects against is crediting a deposit twice, and the only thing that would tell you is a check you wrote yourself.
T+1 · Stabilise
Do not turn the old system off. Leave it reading, not writing, for at least a full reconciliation cycle. The reason to keep it is not that ChainOS might be wrong; it is that your own integration might be, and the old system is the only independent record you have.
Work through, in this order:
- Any address in your database that is not in the reconciliation export.
- Any
spendability: watch_onlyyou did not expect — usually a key source registered after the import, which stops being watch-only the moment you register it, without re-importing. - Withdrawal refusals by code.
INSUFFICIENT_GASon token sends is normal and means you need gas tanks and sweep policies; see Customer wallets. - Sweep policies, once deposits are flowing. Migrated addresses are ordinary deposit addresses and consolidate like any other.
T+30 · Decommission
Three things survive the old system, and all three are yours to keep:
The mnemonic or key sources. Still the only thing that can spend. Nothing about migrating changed that.
Your address-to-customer mapping. ChainOS holds userRef, which is your identifier — but
your own table with derivation_path per address is what lets you recover funds with no
ChainOS at all. Back it up with the same seriousness as the seed.
Old addresses stay monitored, indefinitely. After consolidation, after you believe the migration is finished, forever. Your customers send to addresses you issued years ago. To stop watching a specific one, archive it individually — a rollback is not an archival tool and archiving is not a rollback.
Leaving later
GET /v1/migration/projects/{projectId}/export?format=CSV
Every address the project adopted, in any of the four formats the import accepts — so an export
re-imported elsewhere produces the same book. Ask GET .../export/plan?format=XPUB first and
it tells you the row count and whether that format can express your project at all.
The two key formats are sometimes impossible rather than unbuilt: if your addresses came from a key source your Edge holds, ChainOS has never held its extended public key, only a fingerprint and the addresses it produced. There is no file we can write, and the plan endpoint says so in as many words.
This exists so that leaving is possible. There is no lock-in to protect, because there is nothing we hold that you do not.
Common ways this goes wrong
| Symptom | Almost always |
|---|---|
| The scan found one address | Ledger Live path against an account-level key. See T−3 |
| The scan found 40% of the book | probeTokens unset, or gapLimit too low for sparse issuance |
Every row fails chain_format | The address column is not the address column |
Every row fails derivation | The wrong base path, or columns misaligned by one |
| Your webhook received a year of deposits | You replayed history yourself. The watermark does not do this |
| Withdrawals refused after cutover | No key source for that chain, or Bitcoin/TRON/XRP with a source registered |
| A customer says they paid and you have no record | An address that was never in the import. Check the reconciliation export |
Next
- Migration — the reference, in full.
- Customer wallets — what to configure once the book is live.
- Reconciliation — the ongoing check, after the migration one ends.