Skip to main content

Migration

You already have a working system. You hold keys, you have derived addresses across several chains, you have issued those addresses to your own customers, and you have a mapping from address to customer that is your business.

Migration brings that book onto ChainOS without disrupting your end users and without a single deposit going undetected. Your existing deposit addresses keep working. Nothing has to move on-chain.

Your keys never reach ChainOS

There is no endpoint that accepts a recovery phrase or a private key, no upload in the portal, and no support channel for one. Key material is loaded into your Edge by a local command on your own machine. ChainOS learns a fingerprint and extended public keys — which derive addresses and no private key.

This is enforced by there being nowhere to put it, not by a rule somebody remembers.

Which case are you in?

Six discrete situations, not a spectrum. Each has a different answer, and working out which one applies is the first job — most people do not know, which is what the assessment is for.

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.

CaseWhat you haveWhat happens
AOne recovery phrase, standard derivationNot really a migration. Set PASS_PHRASE to your existing phrase and your addresses simply are ChainOS addresses
BOne phrase, a derivation path you do not recognise — or are not sure aboutThe path is detected empirically from one known address
CSeveral phrasesOne becomes your Edge's primary; the rest are imported as key sources
DIndividually generated keys, no shared seedRaw key import. No discovery is possible — you supply the address list
EA mixture of the aboveOne project holds sources of different kinds at once. This is the case the design is built around
FA custodian holds your keysWatch-only, and consolidation too if your provider will sign

"I am not sure" is a real answer

standardWallet takes three values, and the third matters. true for MetaMask, Trezor or similar; false for something bespoke; and null for unsure, which is the common answer.

Unsure routes to Case B, where the path is worked out from an address rather than guessed. 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.

Case F, honestly

If a provider holds your keys, the question that decides everything is whether they will sign transactions you construct.

They will: adopt the addresses watch-only so monitoring starts immediately, then consolidate — ChainOS builds the unsigned transactions, your provider signs them, ChainOS broadcasts.

They will not: watch-only, permanently. Deposits are detected, confirmations tracked, webhooks fired and balances queryable; withdrawals are refused with 422 ADDRESS_WATCH_ONLY. This is a supported configuration and not a degraded one — it is the right answer during a parallel run and for winding a provider down. We say so plainly because you will find out either way, and finding out later costs you a migration you had already started.

Adopt, not consolidate

Adopt means ChainOS starts monitoring addresses it did not derive. Nothing moves. Your end users keep their existing deposit addresses and notice nothing at all.

Consolidate means generating fresh addresses and moving funds to them — which means telling your retail customers their deposit address has changed, which they will resist, and which guarantees a long tail of deposits to abandoned addresses.

Adopt is strongly preferable and is the default everywhere in the product. Consolidation is correct only when keys cannot be obtained, when you want a clean cryptographic break, or when the legacy derivation is genuinely unsupportable.

Even after consolidation, old addresses stay monitored forever. Consolidation moves the balance; it does not end the obligation. End users send to old addresses for years.

Working out your derivation path

For Case B. Give one address you are certain is yours and every candidate path is tried against it — BIP-44, 49, 84 and 86 for Bitcoin, the standard and Ledger Live variants for EVM.

POST /v1/migration/detect-path
{ "chain": "eth", "xpub": "xpub6D…", "knownAddress": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" }

This takes an extended public key. If you hold only a recovery phrase — which is the normal situation at the moment somebody asks you what your derivation path is — run the same search on your own machine instead:

chainos-edge detect-path --address=0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD

It prints the extended public key to register. Nothing leaves the machine: producing the key for a candidate path needs the seed, which is why this cannot be done in the cloud.

A match at index 0 is ambiguous

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 because 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.

Send a second known address. Ideally your second one.

Registering the wallets you already had

An address ChainOS monitors but cannot sign for is watch-only: deposits are detected, credited and webhooked exactly as normal, and a withdrawal from it is refused with ADDRESS_WATCH_ONLY. That is a first-class state and not a degraded one — a customer whose custodian will not export keys lives in it permanently and the product works.

For everybody else it is temporary, and this is how it ends. Registering the wallet an address came from is what makes it spendable.

There is no API for this

Registration begins on your Edge, with a command, on the machine that holds the key material. There is no endpoint for it here — not for the portal, not for support, not for automation — because every version of that endpoint is one that accepts a recovery phrase.

chainos-edge import-mnemonic \
--label="Old MetaMask" \
--file=/run/secrets/old-wallet \
--chains=eth,btc \
--derive-count=1000

--file is the recommendation, for the same reason PASS_PHRASE_FILE is: an environment variable leaks through docker inspect, /proc/<pid>/environ, process listings, crash dumps and most orchestrator UIs, and a file mounted at 0400 from a secret does not. You can also pipe the phrase in — pass show old-wallet | chainos-edge import-mnemonic --label=… — which never puts it on a disk at all. There is deliberately no --mnemonic flag: an argument lands in shell history and in every process listing on the machine.

What reaches ChainOS is a fingerprint, extended public keys, and the addresses they derive. Nothing else, and there is no field on that request that could carry anything else.

The Edge keeps nothing

import-mnemonic reads the phrase, derives, registers and drops it. It writes a manifest to EDGE_DATA_DIR — a label, a fingerprint, the chains, the base path, and the path of the file to read the material from — and no key material of any kind.

Two things follow from that, and both are worth knowing before you run it:

  • Mount the secret on every Edge replica that should be able to sign with it, exactly as you already do for PASS_PHRASE. A replica without it will refuse a withdrawal from those addresses.
  • If you piped the phrase in, nothing on the machine holds it. ChainOS now treats those addresses as spendable and your Edge cannot sign for them after a restart. The command says so; put the phrase in a file and re-run with --file.

Signing with a registered wallet

Registration makes an address spendable in ChainOS. Signing for it is a second thing, it happens on your Edge, and the two are worth keeping apart in your head because they can be true on different machines.

Your Edge reads each registered source's secret at boot, opens the wallet beside your primary one, and derives the addresses it registered so it knows which it holds keys for. So:

  • A replica without the secret mounted refuses withdrawals from those addresses, while another replica signs them. That is a configuration difference, not an outage, and it is the one worth catching before traffic does.
  • Mounting a secret on a running Edge does nothing until it restarts. A signer whose wallet set can change while it is running is a signer whose refusals depend on when they were asked.
  • chainos-edge list-key-sources --check answers "can this machine sign for these" as opposed to "was this machine told about these". Run it on every new replica.

When a withdrawal arrives, your Edge re-derives the sender from your primary phrase; if it does not match, it looks the address up among the wallets it loaded and signs only if one of them actually derives it. ChainOS knows which wallet owns which address and is never asked — the routing is by derivation on your machine, so a compromised ChainOS could not name a wallet into signing something. A sender no wallet on the machine derives is refused, and the refusal names the sources whose material is not mounted there:

the derived address does not match the transaction's sender; refusing to sign
(path index 0 derives 0x9858…, transaction says 0x58A5…). No wallet loaded on this
Edge derives that address, and 1 registered key source(s) have no key material here:
Old MetaMask.

Individual keys with no shared seed

Case D. One record per line, either a bare private key or address,privateKey:

chainos-edge import-keys --label="Legacy keys" --file=/run/secrets/keys.csv

Every line that names an address is checked against the key on it, and a single mismatch refuses the whole file. That refusal is the point of the command rather than a nicety. A wrong pairing registers an address as spendable through a key that cannot sign for it, and you find out when a withdrawal is accepted here and refused at your own Edge — with your end user already told the money is on its way.

Export your file as address,privateKey if you can. A file of bare keys imports perfectly and proves nothing.

Order does not matter

Import your addresses first and register the keys later, or the other way round. Both work, and the first is the sequence most people follow — you will want your book monitored before you have found the old wallet's recovery phrase.

Spendability is resolved automatically on both events: when a wallet is registered, and on every import commit. Addresses adopted watch-only in March become spendable the moment the keys arrive in April, without re-importing anything. The command tells you how many:

Registered with ChainOS: 1000 address(es) recorded.
4,182 address(es) you had already imported are no longer watch-only and can now be
withdrawn from.

That second line is the one to read. A registration that covers nothing and one that unlocks a book of four thousand look identical without it.

POST /v1/migration/key-sources/{id}/resolve runs the same pass by hand. You should not need it — it is idempotent, and running it is never worse than not running it — but it exists for the cases the automatic triggers miss: an address unarchived after a registration, or one adopted by a batch that named no key source.

Bitcoin, TRON and XRP register a key and no addresses

Your Edge carries address encoders for EVM chains only. Encoding a bc1q…, a TRON base58check or an XRP classic address lives in ChainOS's chain adapters, and duplicating those three codecs in the Edge would be three more implementations able to disagree with the one that matters.

So a Bitcoin source registers its extended public key and ChainOS derives the range from it — the same division of labour your gas tank already uses. --derive-count is how deep. You will see the chain listed as skipped in the command's output; that is the design, not a failure of your key.

Withdrawals on those chains are refused once a key source covers them

Monitoring, crediting and webhooks work exactly as on every other chain. Signing does not. Because your Edge cannot encode a bc1q…, it cannot tell whether a Bitcoin sender is one of your primary wallet's addresses or one of the registered wallet's — and signing with the wrong one produces a valid-looking signature for a key that does not own the coins, which costs a broadcast to discover. So it refuses, by name:

this Edge cannot tell which wallet the sender belongs to; refusing to sign: btc is
registered to a key source on this Edge, and no address codec for btc exists here…
Only EVM chains can be signed from a registered key source today.

This applies only once you have registered a Bitcoin, TRON or XRP key source on that Edge. Until then those chains sign from your primary phrase as they always have. Move funds on those chains from the wallet that holds them, and register the source for monitoring.

What is checked, and what happens if it fails

Every address the Edge pushes is re-derived here from the extended public key sent alongside it. One that does not match refuses the whole registration with KEY_SOURCE_MISMATCH and writes nothing:

{
"code": "KEY_SOURCE_MISMATCH",
"detail": { "chain": "eth", "index": 0 }
}

The index is the useful part. A mismatch at index 0 usually means the base path is wrong for the wallet — run chainos-edge detect-path against an address you are certain of and register the path it reports. A mismatch deep into the range usually means the extended public key and the address list came from different wallets.

Purging

When the key material is gone from your Edge, say so:

chainos-edge purge-key-source --source=a1b2c3d4 --reason="Wallet decommissioned"

Every address that wallet made spendable goes back to watch-only, and all of them go on being monitored. Deposits are still detected, credited and webhooked exactly as before. You removed a key, not an address, and your end users will send to those addresses for years.

The source, its keys and its address list are kept, and the row stays on your key sources screen marked purged. A demoted address with nothing to explain it is the question somebody asks six months later.

The command reports the purge and removes the local record. Removing the key material itself is yours to do — on every replica it is mounted on. Nothing here can unmount your secret or revoke it from your secret manager, and pretending otherwise would be the one place a tool like this should not be reassuring.

If the wallet comes back, register it again. The fingerprint is computed from public material so it is the same source, revived, and its addresses become spendable again.

Discovery

If you have an HD wallet, do not hand-build an address list. Every address you type is one you can mistype, and the failure is silent — a missed address is not an error, it is a deposit nobody detects.

POST /v1/migration/{projectId}/discover
{ "keySourceId": "…", "chain": "eth", "gapLimit": 20, "maxScan": 5000,
"probeTokens": ["USDT", "USDC"],
"userRefMappingCsv": "0,cust-1041\n1,cust-1042\n" }

Discovery derives along your path and probes each address for activity, stopping after gapLimit consecutive unused indices. It returns immediately; poll the scan for progress.

userRefMappingCsv is the field that matters. Discovery finds addresses; only you know which of your customers each one 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 header row and blank lines are fine. A duplicate index is refused, because two of your customers cannot hold one address and guessing which is right would attribute their deposits to the wrong person.

Set probeTokens. 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.

Used is not the same as funded

An address that received a deposit and had it swept holds nothing and is very much in use. Discovery probes activity — the transaction count as well as the balance — precisely so that a swept address does not read as a gap and end the scan in the middle of your book.

The cost, and the ceiling

ChainHow activity is checkedCost
BitcoinOne call for the whole derivation rangeVery low
Ethereum · BSC · Polygon · Avalanche · BaseTransaction count and balance, per addressModerate
TRONAccount lookup, per addressModerate
XRPAccount lookup — absent means never usedLow

A 5,000-address scan across five EVM chains is 25,000 provider calls. Discovery is therefore rate-limited and runs in the background rather than blocking.

maxScan defaults to 10,000. A higher value is not refused — the scan runs at 10,000 and a Ziklag administrator is asked about the rest. Refusing outright would send away a customer with a genuinely large book; running unbounded is a provider-cost incident.

Read truncated separately from the count. A scan that stopped at the gap limit found the end of your wallet. One that stopped at the ceiling ran out of permission — and only the second means addresses were probably missed.

Not available on Solana or Stellar

Both use Ed25519, which 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 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.

What adopting will cost

GET /v1/migration/projects/{projectId}/capacity

The capacity and billing consequence of adopting everything discovered so far — before you commit to it. Adopting ten thousand addresses is a billing event as much as a technical one, and finding out afterwards means being charged for a decision nobody offered you.

Measured against your committed band alone, not against any temporary capacity Ziklag has granted you. A grant expires, and a projection leaning on one would promise capacity that gets taken away on a date you never agreed to.

Importing your addresses

Four steps, with a mandatory pause in the middle: upload, check, review, adopt. The pause is the design rather than an inconvenience — adopting a book of addresses changes what you are billed, changes what ChainOS watches on your behalf, and stops being free to undo the moment one of your customers sends money to one of the addresses.

Upload

POST /v1/migration/uploads

Returns a URL to PUT your file to, and a deadline. Two steps rather than one because the files are too big for a request body: a million-row JSONL is around 150 MB and a form upload caps out at 2 MB.

Where object storage is configured the URL is presigned and the bytes never pass through the ChainOS API at all. Where it is not, the URL is an endpoint on this API. Your code is the same either way — PUT to uploadUrl with the headers you are given, then:

POST /v1/migration/uploads/{uploadId}/complete

which tells you how many bytes actually arrived. That number is measured rather than taken from your request, and it is worth checking: a truncated upload parses perfectly up to the cut and reports a smaller book, which is the one failure here that produces a plausible wrong answer instead of an error.

Four formats, detected

FormatWhat it isWhen to use it
CSVA header and one address per lineAlmost always. Column names are matched loosely
JSON LinesOne JSON object per lineLarge exports, and anything programmatic
Output descriptorBIP-380, checksummedBitcoin. See below
chainos-xpub-v1Key-value lines naming a chain, an xpub, a path and a rangeA key and a range, on any chain

CSV is read the way a real export arrives. A byte-order mark — which Excel writes on every UTF-8 CSV it saves — does not hide your first column. Quoted fields may contain commas. A file with no header at all is read positionally as address, chain, user_ref, label. Column names are matched against several spellings each, so Wallet Address and Customer ID are understood without renaming anything.

JSON Lines is preferred over a JSON array, and an array is refused with that explanation. An array has to be read whole, so one bad character anywhere makes the entire file unreadable; JSON Lines is a million independent decisions, and line 400,000 can be malformed while the other 999,999 import.

Bitcoin: use a descriptor

An output descriptor encodes the script type and the derivation path together, in one string, with a checksum over the whole of it:

wpkh([73c5da0a/84h/0h/0h]xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7Wy…/0/*)#afwvtk2s

That removes an entire class of Bitcoin migration error. A base path and an address list are two facts that can disagree — m/49'/0'/0' with bc1q… addresses is somebody who read the wrong row of their own documentation — and the mismatch is only discovered when derivation verification fails on every line. A descriptor cannot disagree with itself.

A missing checksum is accepted; a wrong one is not. Somebody who typed the descriptor by hand has no checksum to give; somebody whose checksum does not match has a corrupted descriptor, and deriving five thousand addresses from it would produce five thousand addresses belonging to nobody.

Create a batch, then check it

POST /v1/migration/projects/{projectId}/imports
POST /v1/migration/imports/{batchId}/validate

One batch is one source. A Case E customer imports a CSV of raw-key addresses, then a scan of their primary mnemonic, then a scan of a foreign one — three batches, one project, one book.

keySourceId decides whether these addresses can ever send. Name the key source they were derived from and every row is checked against its extended public key; leave it out and they are adopted watch-only. Watch-only is monitored and credited, with withdrawals refused — a first-class state, not a degraded one, and it stops being true the moment you register the key source, without re-importing anything.

The seven phases

Validation runs seven phases in order, and the phase a row failed at is the diagnosis:

PhaseWhat it checksWhat a run of failures usually means
parseCould the line be read at allA stray character, or the wrong format
schemaAre the fields the right shapeAn unknown chain, or an index that is not a number
chain_formatIs this a real address on this chainThe address column is not the address column
derivationDoes your key produce this address hereThe wrong base path, or misaligned columns
uniquenessHas anyone claimed it alreadyThe file was already imported
spendabilityWhich key, if any, can signNo key source named
projectionWhat adopting all of it costs

Three outcomes are deliberately not errors:

  • Already in your account → skipped. Correcting twelve rows and re-uploading your whole export is safe, and that is what makes this pipeline usable more than once.
  • EIP-55 casing that does not match → a warning. A lowercase address is the same address, and it is what most databases store. It is stored checksummed.
  • A row that could not be traced to your key → adopted watch-only. Refusing it would throw away a real address; recording it as spendable would accept a withdrawal that fails at your Edge after you were told it worked.

One uniqueness failure is different from the rest: an address another ChainOS account already claims fails the whole batch, not the row. Only one of the two claims can be right and getting it wrong routes somebody else's deposits to you, so a person reviews it. Neither side is ever told who the other is — you both get the same opaque reference, and an operator holds both halves.

The dry run, and why it has an id

GET /v1/migration/imports/{batchId}/dry-run

The report carries a dryRunId, and the commit has to quote it. Re-validating mints a new one, so a commit quoting the old one is refused with 409 DRY_RUN_STALE — because the report you read is no longer what would be committed, and the addresses, the capacity consequence and the bill may all have changed.

There is no way to commit without quoting one. Reviewing the dry run is structural rather than advised.

byPhase is the field to read first. Four thousand rows failing chain_format is one problem, not four thousand, and the count beside the phase is what shows you that.

Adopt

POST /v1/migration/imports/{batchId}/commit
{ "dryRunId": "…", "acceptBillingChange": true }

acceptBillingChange is required only when adopting the book takes you past your band, and it is asked separately from the commit itself: 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.

Every adopted address lands monitored, credited, queryable and silent. See observe mode below.

Historical transactions never fire webhooks

This is the invariant with the highest cost of being wrong in the whole product, so it is worth stating plainly.

At the moment you commit, ChainOS records the current block height of every chain your import touches against each address it adopts. A transaction in an earlier block is history you already settled in whatever system you were using before, and it is recorded, queryable, and never announced. Firing a webhook for it would ask you to credit the same deposit twice.

The watermark is permanent. It is not a window that expires, and cutting over does not clear it — cutting over starts the future, it does not replay the past. If it did, the first reconciliation sweep afterwards would announce a year of your deposits as new.

The commit refuses rather than adopting without a watermark. If a chain cannot be reached, the commit fails with CHAIN_UNAVAILABLE and nothing is imported — waiting five minutes costs nothing next to replaying your settled history.

Observe mode

GET /v1/migration/projects/{projectId}/observe

Adopted addresses are monitored, their deposits are recorded, and they are queryable through the ordinary endpoints — and they fire no webhooks and accept no withdrawals until you cut over. They still count toward your capacity, because they are still being watched.

The point is that you can run your old system and this one against the same addresses and compare. depositsObserved is the number to reconcile; historicalRecorded is the count of transactions that predate your import and stayed silent.

GET /v1/migration/projects/{projectId}/reconciliation

is the CSV to open beside your own export — every address, its derivation path, your own reference for it, and whether anything can sign for it.

Cutting 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.

POST /v1/migration/projects/{projectId}/deactivate

holds them again, and is deliberately gated more lightly than the cutover it reverses. 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 cutting over again later announces nothing that happened in between.

Undoing an import

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 one of your customers has sent money to an address, removing it would leave a confirmed deposit belonging to an address your account no longer has, and your own ledger would be the only surviving record of it.

Historical transactions do not close the window — those are the ones the watermark already silenced, and they belong to the book you are undoing. They are discarded with it.

The validated rows are kept, so you can correct your file and import again without re-uploading what was already right.

Undoing is not the same as archiving

A rollback removes addresses that were never monitored for long and never announced anything. Adopted addresses are otherwise monitored indefinitely — after consolidation, after you believe the migration is finished, forever. Your customers send to old addresses for years. To stop monitoring a specific address, archive it individually.

ChainOS never receives key material

There is no endpoint that accepts a recovery phrase or a private key, no column in the database that could hold one, and no support channel for one. Key material reaches your Edge through a command you run on your own machine and by no other route.

Requests are also checked for it. A body that looks like a recovery phrase or a private key 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 a little over-eager: twelve or more consecutive words from the BIP-39 list will be refused even in a label, and so will a bare 64-character hex string. That trade is the right way round and it is not close. A false refusal costs one confused support conversation; a false accept means your seed is in our request logs, and no amount of deleting afterwards makes that not have happened.

Rehearsing it first

Everything on this page works in the sandbox against a sandbox key, with real addresses derived from a real sandbox extended public key — so a rehearsal exercises genuine address formats, genuine validation and the genuine pipeline. Two things are worth rehearsing specifically, because they are the two you cannot check by reading your file.

POST /v1/sandbox/migration/projects/{projectId}/simulate-activity

Writes deposits against the project's adopted addresses, some below each address's webhook watermark and some above it, and tells you how many went on each side of the line.

Then check your endpoint. It should have received the ones above the watermark and none of the ones below it. That is the invariant that matters most in a migration: a deposit your old system already settled must not arrive at your webhook as though it were new, because the obvious thing to do with a new confirmed deposit is credit it — and crediting it twice is not a cosmetic problem.

curl -X POST https://api.chainos.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}'

POST /v1/sandbox/migration/projects/{projectId}/simulate-conflict

Registers one of your addresses to a second account, so the next import naming it is refused exactly the way a real conflict would be.

This happens in production more often than it sounds like it should: 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. Address identity is per-account by design, so nothing stops the collision at write time. What happens instead is that the import detects the claim and escalates it to a person — because a dispute between two customers about who owns an address is not something software should settle.

Call clear-conflict on the same project when you have seen it.

Getting your book back out

GET /v1/migration/projects/{projectId}/export?format=CSV returns every address the project adopted, in any of the four formats the import accepts — so an export re-imported elsewhere produces the same book.

FormatWhat it contains
CSVOne row per address: address, chain, derivation path, index, your reference, label, state
JSONLThe same, one JSON object per line
XPUBchainos-xpub-v1 — extended public keys and their ranges rather than the addresses
DESCRIPTORBIP-380 output descriptors with checksums. Bitcoin only

Ask GET .../export/plan?format=XPUB first and it will tell you the row count and whether that format can express your project at all, before a download starts.

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. Export CSV or JSONL, which carry every address individually, or export the key from the wallet that holds the mnemonic.

Worth saying plainly: this exists so that leaving is possible. There is no lock-in here to protect, because there is nothing we hold that you do not — the keys were always yours, and this is the addresses.

What is not built yet

Discovery produces candidates, not adopted addresses. A scan finding an address does not start monitoring it — adoption happens at an import commit, which is where the capacity check, the billing consent and the historical-transaction watermark live.

Consolidation is built for the chains whose addresses the Edge can verify by re-deriving them. Bitcoin, TRON and XRP are refused rather than guessed at: the Edge carries no address codec for those three, so it cannot check that a consolidation destination is one of yours, and signing on a destination it cannot verify is how funds go to the wrong place with a well-formed signature on them.