Skip to main content

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

Seven stages from assess through register, discover, adopt, cut over, stabilise and decommission — with a parallel run across the two stages before cutover, during which nothing is announced and both systems are live.

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.

CaseYou haveThe consequence
AOne phrase, standard derivationBarely a migration. Point your Edge at the existing phrase and the addresses simply are ChainOS addresses
BOne phrase, a path you cannot nameThe path is detected empirically from a known address
CSeveral phrasesOne becomes the Edge's primary; the rest register as key sources
DIndividual keys, no shared seedRaw import. No discovery — you supply the list
EA mixtureOne project, several sources. This is the case the design was built around
FA custodian holds your keysWatch-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.

Gate T−4

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.

Send a second known address — ideally your second one

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.

Gate T−3

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.

Discovery is impossible on Solana and Stellar

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:

PhaseA run of failures usually means
parseA stray character, or the wrong format
schemaAn unknown chain, or an index that is not a number
chain_formatThe address column is not the address column
derivationThe wrong base path, or misaligned columns
uniquenessThe file was already imported
spendabilityNo 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.

Gate T−2

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 book3–5 days
Retail deposits with a weekly rhythm2 weeks
Anything with month-end concentrationThrough 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.

Gate T−1
  • depositsObserved matches 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

WatchHealthy
Webhook delivery success rate~100%; investigate any dead letters immediately
Deposits credited by your system vs depositsObservedEqual
Withdrawal attemptsSucceeding, or refused with a code you recognise
ADDRESS_WATCH_ONLY refusalsOnly on addresses you know are watch-only
Your own duplicate-credit alarmSilent

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:

  1. Any address in your database that is not in the reconciliation export.
  2. Any spendability: watch_only you did not expect — usually a key source registered after the import, which stops being watch-only the moment you register it, without re-importing.
  3. Withdrawal refusals by code. INSUFFICIENT_GAS on token sends is normal and means you need gas tanks and sweep policies; see Customer wallets.
  4. 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

SymptomAlmost always
The scan found one addressLedger Live path against an account-level key. See T−3
The scan found 40% of the bookprobeTokens unset, or gapLimit too low for sparse issuance
Every row fails chain_formatThe address column is not the address column
Every row fails derivationThe wrong base path, or columns misaligned by one
Your webhook received a year of depositsYou replayed history yourself. The watermark does not do this
Withdrawals refused after cutoverNo key source for that chain, or Bitcoin/TRON/XRP with a source registered
A customer says they paid and you have no recordAn address that was never in the import. Check the reconciliation export

Next