Skip to main content

HD derivation

Everything ChainOS issues comes from one BIP-39 mnemonic through BIP-32 hierarchical deterministic derivation. This page is the reference for the paths, and the explanation of the one asymmetry the whole product rests on.

The asymmetry

A BIP-32 extended key is a key plus a 32-byte chain code. From an extended private key you can derive child private keys and child public keys. From an extended public key you can derive child public keys — and therefore addresses — but not child private keys.

The private row runs extended private key to child private key to private key to signature; the public row runs extended public key to child public key to public key to address. The private row derives the public row, never the reverse.

Ziklag Cloud is given the bottom row, and nothing else.

Cloud holds the bottom row. It can derive every address you will ever issue and cannot produce a single signature. That is arithmetic, not a policy Ziklag adopted.

The one caveat: this only holds for non-hardened child derivation. Hardened derivation (paths with ') deliberately requires the parent private key, which is why the account level is hardened and the branch below it is not.

The paths

ChainAccount pathCurveCloud storesPublic derivation
Bitcoinm/84'/0'/0'secp256k1zpubM/0/{i}
Ethereumm/44'/60'/0'secp256k1xpubM/0/{i}
BSCm/44'/60'/0'secp256k1shares Ethereum'sM/0/{i}
Polygonm/44'/60'/0'secp256k1shares Ethereum'sM/0/{i}
Avalanchem/44'/60'/0'secp256k1shares Ethereum'sM/0/{i}
Basem/44'/60'/0'secp256k1shares Ethereum'sM/0/{i}
TRONm/44'/195'/0'secp256k1xpubM/0/{i}
XRPm/44'/144'/0'secp256k1xpubM/0/{i}
Solanam/44'/501'/{i}'/0'Ed25519address pool❌ Edge only
Stellarm/44'/148'/{i}'Ed25519address pool❌ Edge only
Edge identitym/83696968'/0'/0'Ed25519public key only❌ Edge only

Bitcoin uses BIP-84 (84') because ChainOS issues native segwit addresses; the other secp256k1 chains use BIP-44.

83696968' is BIP-85's namespace — the standard for deriving other keys from a BIP-32 master — which is where the Edge identity key belongs rather than in a coin-type slot.

Coin types are not interchangeable

ETH, BSC, Polygon, Avalanche and Base genuinely share m/44'/60'/0', so one extended public key serves all five and one address receives on all five. That is correct and intended.

Note that this is a fact about the coin type, not a list of five chains somebody maintains. Avalanche and Base make the point from opposite ends: one is not an Ethereum network at all — separate L1, separate consensus, separate validators — and the other is a rollup that settles to Ethereum. Neither needed derivation code of its own; both joined by declaring the same path. Sharing an address model is not sharing a chain, and it is emphatically not sharing a balance: the same address holds ETH on Ethereum, AVAX on Avalanche and a different ETH balance on Base, and funding any one does nothing for the others.

Base is the case where that is easiest to miss, because it shares Ethereum's asset symbol as well as its address — see Base.

TRON at 195', XRP at 144' and Stellar at 148' do not share it, and treating any of them as if they did would be a serious bug: an aliasing defect in a key-derivation library once made TRON's extended public key byte-identical to Ethereum's, which would have monitored TRON deposits at a key derived for a different chain. There is now a contract test that asserts both the sharing and the not-sharing, because "these three are the same and those three are not" is exactly the kind of invariant that decays silently.

The two branches

The child index after the account level is the BIP-44 change branch:

  • M/0/{i} — receive. Every customer deposit address. Cloud allocates the next i from a per-account, per-chain counter.
  • M/1/0 — change. Where the gas tank lives, one per chain, carrying role = gas_tank in the address table.

Putting the tank on the change branch is structural rather than conventional: Cloud only ever derives the receive branch when allocating an address, so a tank is impossible to issue to a customer by accident. Reusing a deposit address for it would attribute gas spend to a customer and make reconciliation report a permanent discrepancy against them, and it would put two contradictory policies on one address — a sweep wants to empty a deposit address, and a tank must never be emptied.

Why Solana and Stellar have pools

Ed25519 has no public-key-only child derivation. There is no such thing as a Solana or a Stellar extended public key, so Cloud cannot derive addresses on either chain at all — not as a limitation of this implementation, but as a property of the curve. The two chains take the pool path for one reason, and the code selects it on the curve rather than on the chain, which is why a third Ed25519 chain would need no new mechanism.

So the Edge derives a batch of addresses per pooled chain (1000 by default, EDGE_POOL_SIZE), sends the public addresses to Cloud, and Cloud allocates from that pool with SELECT … FOR UPDATE SKIP LOCKED — which is what lets a hundred concurrent allocations produce a hundred distinct addresses without serialising the table.

The two pools are independent: separate index sequences, separate low-water thresholds, and different path shapes. Solana's index sits at the account level with a further change segment below it, while SEP-0005 stops at the account — so index 7 on one chain has nothing to do with index 7 on the other. Both are fully hardened, which Ed25519 requires.

When a pool drops below 20% the readiness indicator for that chain turns orange and Cloud asks the Edge for more over the open stream. If no Edge is connected and the pool is exhausted, allocation fails with 423 EDGE_OFFLINE rather than issuing an address nobody holds a key for.

Practical consequence: Solana and Stellar address issuance needs a live Edge; the other seven chains do not. See Solana and Stellar.

derivationPath is absolute

Every address reports its full path from the master:

{ "address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
"derivationPath": "m/84'/0'/0'/0/0" }

Absolute, not M/0/0. A relative path is expressed against an account extended public key you never receive, so it cannot be used to recover a key — which defeats the only reason this field exists.

Recovering funds without ChainOS

This is the property that makes the whole arrangement safe to depend on. With your mnemonic and an address's derivationPath, any standard BIP-32 tool recovers the key. No ChainOS, no Ziklag, no API.

# Export your addresses and their paths first — while you still have an account.
curl -s "$EDGE/addresses?size=1000" \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
| jq -r '.data.content[] | [.chain, .address, .derivationPath, .userRef] | @csv' \
> chainos-addresses.csv

Keep that export beside your mnemonic backup and refresh it periodically. The mnemonic alone is enough to recover funds, since the paths are deterministic and documented above; the export is what lets you recover the mapping from address to customer, which is yours and which ChainOS is the only holder of.

Then, offline, with a tool of your choosing:

mnemonic + "m/84'/0'/0'/0/0" → private key → sign → broadcast

The two Ed25519 chains recover the same way, with one caveat: their paths are fully hardened and the key derives under SLIP-0010 rather than BIP-32's secp256k1 rules. Any Solana or Stellar wallet that accepts a BIP-39 mnemonic and a custom path — m/44'/501'/{i}'/0' and m/44'/148'/{i}' respectively, the latter being SEP-0005 — does this correctly.

Address reuse

One address per customer per chain, indefinitely. ChainOS does not rotate deposit addresses, because a fintech's customer keeps the account number they were given and a rotating deposit address produces deposits to addresses your customer saved months ago.

All ten chains work this way, including XRP and Stellar — see XRP for why that is a change from an earlier revision that used one shared account with destination tags, and Stellar for the same argument made against memo IDs.

Further reading