CKB KeyWayDocs

Account address & recovery

Opt-in backups that can move a user's funds without KeyWay, why turning them on changes the address once, and what it costs.

A KeyWay account is controlled by the user's Lit key, reached through their email. Account recovery is an opt-in extra: the user names backups, an EVM wallet or a JoyID passkey, that can move everything in the account to a wallet of their choice without KeyWay. If KeyWay, Lit or your app goes away, a backup and a public CKB node are enough.

Recovery runs on CKB testnet. It is off until the user turns it on.

The address changes once

Without recovery, the account's address is a standard secp256k1 address of the Lit key. Turning recovery on moves the account to an Omnilock address in administrator mode:

args = 0x00 ‖ blake160(Lit public key) ‖ 0x01 ‖ AdminList type hash
  • The Lit key stays the everyday signer (Omnilock's owner path). Signing, Fiber and approvals work as before.
  • The AdminList is a cell that holds the backup list (a sparse Merkle tree root, with a Type ID so its hash never changes). A backup signs through Omnilock's administrator path with a proof that it's on the list.
  • The AdminList has its own lock: the same Omnilock and Lit key without administrator mode (0x00 ‖ blake160(Lit public key) ‖ 0x00). Only the Lit key can spend it, through KeyWay's own list update. Backups can't spend, move or rewrite it. Omnilock finds the list by its type hash alone, so its lock isn't part of the address.
  • Adding or removing a backup only rewrites the AdminList. The address doesn't change again.
  • Omnilock is referenced by its data hash (data1), not by type, so nobody can change the code under an account. On testnet the code cell is KeyWay's own copy of the official binary, under a lock nobody can unlock. The record of each account stores the code hash and cell deps it was turned on with.

Turning recovery on is one transaction, signed by the Lit key: it creates the AdminList and moves the account's plain CKB and tokens to the new address. Cells it can't move (Nervos DAO, NFTs, cells with data) stay at the old address, which remains the user's.

The change takes effect once the transaction is committed: recovery.enable() resolves then, and useCkbWallet().address becomes the new address and legacyAddress the old one. Tell users to share only the new one. If the network drops the transaction, nothing changes and the user can try again.

The sign-in check

Funds can still arrive at the old address: old payers, channels opened before, Fiber settlements. Each time a user with recovery signs in, KeyWay moves the plain CKB and tokens it finds there to the new address, in one transaction signed with the user's own key and no dialog. Nothing runs between sign-ins. The SDK doesn't spend from the old address itself, so the two never compete for the same cells.

The record is checked against the chain

KeyWay stores each account's recovery settings, including the transaction that turned recovery on. Before the settings are used (at sign-in, for the sign-in check, and for signing), KeyWay checks on chain that this transaction is committed, that it spent a cell of the user's own secp256k1 address, that the AdminList's Type ID comes from that cell, and that the list has its owner-only lock. Nobody but the Lit key can spend the user's cells, so nobody else can make such a list. The SDK runs the same check on what KeyWay's server returns, so the server alone can't move a user to someone else's list.

Costs

Without recoveryWith recovery
Recovery list (AdminList cell)—165 CKB, locked while recovery is on
Smallest plain CKB cell61 CKB95 CKB
Smallest token cell (xUDT)142 CKB176 CKB

The longer Omnilock lock is what raises the minimums. The preview shows the exact amounts before the user approves. The AdminList's 165 CKB is the user's but stays locked while recovery is on; the rescue page doesn't move it.

Backups

A list holds one to eight backups. Each is an Omnilock exec identity that names one of KeyWay's verifiers (in contracts, referenced by data hash so they can't change) and the backup's ID salted with the account's:

pubkey_hash = blake160(backup ID ‖ AdminList type hash)

The backup ID is the EVM address, or blake160 of the JoyID passkey's public key. The AdminList type hash is the last 32 bytes of the account's address args, so the verifier reads it from the lock it unlocks and checks blake160(signer's ID ‖ salt) against the identity.

Each app gives the same email its own KeyWay account. A user who picks the same EVM wallet or passkey as a backup in two apps gets two unrelated identities, one per account, and the list recorded on chain holds only these salted identities. So nobody reading the chain can link the two accounts through the shared backup. The salt is public, though: someone who already knows a backup's address can check whether it's on a given account's list, and a rescue reveals the backup that signed it. KeyWay's own records and emails still name the real backups.

Both kinds of backup approve a move by signing the same short text, which their wallet shows as written:

KeyWay recovery
Move everything to ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq…
CKB: 9,834.99993392
Tokens: none
Fee: 0.0000246 CKB
Transaction: 0xa2f7a3bbf1ad74bb694e1a834d1ac174b72ec3f3b817a80152f8335004516a5d
  • Move everything to is where every output of the transaction goes, in full address format.
  • CKB is everything the destination receives, including the CKB held by token cells. Tokens counts the token cells. Fee is what the transaction pays the network.
  • Transaction is its signing hash, which binds the signature to this one transaction.

The verifier rebuilds this text from the transaction it runs in and accepts only a signature over exactly that text. It refuses any transaction that doesn't send everything to one address. So a signature can't be replayed on another destination or amount, and a page that asks a backup to sign a move to someone else's address has to show that address. Tell users to read the address before they sign.

EVM wallet. Any wallet that speaks EIP-1193 (window.ethereum). It signs the text with personal_sign. The wallet's network doesn't matter: it only signs a message.

JoyID passkey. The passkey the user's JoyID account was created with. JoyID's page shows the text and the passkey signs it. A passkey synced by the user's platform (iCloud Keychain, Google Password Manager) works on every device it syncs to. Devices added to a JoyID account later and JoyID session keys can't be backups.

Both verifiers are deployed on testnet only.

Adding a backup always needs the backup's own signature over a recovery challenge, so a typo can't name a wallet nobody controls. The challenge includes a one-time request from KeyWay that expires after 15 minutes.

The emailed code

A backup can move everything without KeyWay, so changing who can do that is always confirmed by email:

  • Turning recovery on, adding a backup and removing one always need the six-digit emailed code, even when the user's daily amount would allow a transfer without it. The email names the backup and says what it will be able to do.
  • The last backup can't be removed.
  • Each change takes effect once its transaction is committed. KeyWay then sends a second email with the account's full address, every backup, and a link to the rescue page. It is the user's way back to their funds if KeyWay is gone: ask them to keep it.

KeyWay's server builds and checks every recovery transaction itself. Ordinary signing never spends the AdminList, and funds sent by the turn-on transaction and the sign-in check can only go to the user's own address.

The rescue page

ckbkeyway.dev/rescue moves everything from a KeyWay address to an address the user chooses, signed once by one backup. It needs only:

  • the KeyWay address (from the app or the notice email),
  • one backup on the list,
  • a public CKB node (configurable).

It doesn't call KeyWay's servers, use a database, Lit or any KeyWay secret. It reads the address, finds the AdminList by its type hash under its owner-only lock, rebuilds the list from chain data and checks it against the list's root, shows how many backups it has (the list doesn't name them), finds the connected backup on it by computing its salted identity, shows the balance, and builds one transaction that moves the CKB and tokens. It shows the exact text the backup will sign before asking for the signature.

The balance comes from the node, but the node can't change what a cell holds: the page reads each cell from the transaction that created it and checks that transaction's hash. A node can only leave cells out, so a balance that looks low means trying another node. The page sends only to addresses whose lock it knows (the network's standard locks and KeyWay's Omnilock), so funds can't go to a lock nobody can spend.

The code is in src/rescue, with tests, so anyone can audit it or host it themselves.

What a backup can't move

A backup reaches only what sits at the account's address. These are outside its reach and need KeyWay (and Lit) to move:

  • Fiber channel balances. Funds in an open channel belong to the channel until it closes.
  • Force-close settlements. A force-closed channel pays out to the Fiber node's own key (KeyWay's for hosted nodes); KeyWay moves it to the account afterwards.
  • The fee reserve, 62 CKB kept on the node's key while any channel might need settling.
  • The AdminList's 165 CKB, which only the Lit key can unlock.
  • Cells the rescue page doesn't move: Nervos DAO deposits, NFTs and other contracts' cells, cells with data, and tokens whose type script needs extensions to move. The page lists them.

Keep large balances at the account's address, not in channels, if backups are how users would get them back.

API

import { useCkbWallet } from "@ckb-keyway/react";

function Recovery() {
  const { address, legacyAddress, recovery } = useCkbWallet();

  async function turnOn() {
    const evm = await recovery.createEvmBackup(); // asks window.ethereum to sign the recovery challenge
    const { ckbAddress } = await recovery.enable([evm]); // dialog, then the emailed code
  }

  return <p>Recovery is {recovery.status}, {recovery.backups.length} backups</p>;
}

A JoyID backup needs no extra package. Call createJoyIdBackup straight from the user's click, so the browser lets it open a window:

<button onClick={async () => {
  const proof = await recovery.createJoyIdBackup(); // JoyID connects (first time only), then signs
  await recovery.addBackup(proof); // dialog, then the emailed code
}}>Add JoyID</button>

A CCC JoyID signer still works, as before; KeyWay uses its network, name and icon:

import { ccc } from "@ckb-ccc/core";
import { JoyId } from "@ckb-ccc/joy-id";

const joyId = new JoyId.CkbSigner(new ccc.ClientPublicTestnet(), "Your app", "https://your.app/icon.png");
await recovery.addBackup(await recovery.createJoyIdBackup(joyId));
await recovery.removeBackup({ kind: "evm", address: "0x30033cae613a8331b8a7b756ce46cd7999ec1ef8" });

JoyID on a cross-origin isolated page

JoyID normally answers in its own window, which reports back to the page that opened it. A page that sends Cross-Origin-Opener-Policy: same-origin, as the browser node requires, loses that link, so the answer never arrives. createJoyIdBackup(signer?, { transport }) picks how to reach JoyID:

transportWhat the user sees
"auto" (default)"popup" on a page that isn't cross-origin isolated. Otherwise "window", or "redirect" when that window can't open: the browser blocked it, or the app runs as an installed app.
"popup"JoyID's own window, as CCC's JoyID signer opens it.
"window"A window on your own site opens and goes to JoyID. JoyID sends the result back to it, it hands the result to your page and closes. Your page, and a browser node in it, keeps running.
"redirect"The whole page goes to JoyID and comes back. A browser node is stopped cleanly first; KeyWay refuses to leave while a payment or channel change started through useFiber() is still running.

First use takes two steps. The text a backup signs names its JoyID address, so a JoyID KeyWay hasn't seen yet first connects (JoyID asks for the passkey once) and then signs (once more). KeyWay remembers the connection where CCC's JoyID signer keeps it, so the next backup goes straight to signing.

After a full-page redirect the page reloads, so the promise from createJoyIdBackup never settles. The provider finishes the job on return, before it starts Fiber: it checks JoyID's answer, continues to the signing step if JoyID only connected, then turns recovery on or adds the backup with the usual dialog and emailed code. It turns recovery on while recovery is off and adds the backup while it is on; set afterRedirect: "enable" | "add" to choose, and otherBackups to turn recovery on with other backups' proofs as well. Follow it in recovery.pendingJoyIdBackup:

pendingJoyIdBackup.statusMeaning
checkingThe page came back from JoyID and the answer is being checked.
continuingJoyID connected; the page is going back to JoyID to sign.
applyingThe signature checked out; the user approves and enters the emailed code.
doneRecovery is on, or the backup was added.
cancelledThe user cancelled in JoyID or in the dialog. Nothing changed.
failedSee error. Nothing changed.

If the wallet fails to start after the page returns, the status is failed with waitingForWallet: true: the checked JoyID proof is still held, so starting the wallet again (startFiber()) applies it, and dismissPendingJoyIdBackup() drops it.

Call recovery.dismissPendingJoyIdBackup() once you've shown the outcome. Other options: onStage(stage) reports connecting, signing or leaving, and signal (an AbortSignal) stops waiting for JoyID.

JoyID returns to the page the user started on, or to the provider's joyIdReturnPath (a path on your site, such as /wallet). That page must render KeyWayProvider. A window KeyWay opens for JoyID shows only a short status: the provider doesn't restore the session or start Fiber in it, and replaces your page with that status once it hydrates (your page's own code runs once before that).

Keep the return page minimal

Any script on the return page that runs before the SDK loads, such as analytics or tag managers, can read JoyID's answer from the URL, and the page's own files are requested with the full URL as referrer. Point joyIdReturnPath at a page without third-party scripts if your app has them.

A cancel, in JoyID, in the JoyID window or through signal, rejects with an error whose name is JoyIdCancelledError. Explicit transport: "popup" on a cross-origin isolated page is refused, because JoyID's own window couldn't answer it. After a full-page redirect, a browser node the user started by hand stays stopped until they start it again; with autoStartFiber it starts again by itself.

What KeyWay checks before it uses JoyID's answer:

  • Each request carries a fresh random 128-bit state, recorded once in the tab's sessionStorage and deleted before use, so an answer works only in the tab that asked for it, once, within the request's time.
  • The answer must be for the signed-in user, the step that was asked for, the exact challenge text, and a JoyID main key (ES256). KeyWay checks the passkey signature itself, as the server and the on-chain verifier do.
  • A rejection in JoyID is a cancel.

JoyID writes its answer into the return URL's query. The SDK removes it from the address bar as soon as it loads, before your page fetches anything, but the request for that URL still reaches your server, so don't log query strings on the return page. The answer holds the signed text (account address, JoyID address, a 15-minute nonce) and the passkey's signature, which can only add that JoyID to that account with the user's session and emailed code.

recovery memberBehavior
statuson or off.
backupsThe list, as RecoveryBackup ({ kind: "evm" | "joyid", address }).
legacyAddressThe address from before recovery was on.
createEvmBackup(ethereum?)Gets a challenge from KeyWay, then asks an EVM wallet (window.ethereum by default) for its address and its signature over the recovery challenge. Returns a RecoveryBackupProof, valid for 15 minutes.
createJoyIdBackup(signer?, options?)The same with JoyID. Call it from the user's click. options: transport, onStage, signal, afterRedirect, otherBackups (above). Without a signer, KeyWay talks to JoyID directly.
pendingJoyIdBackupAfter a full-page redirect to JoyID: { operation, status, error? }, while the provider finishes the backup.
dismissPendingJoyIdBackup()Clears pendingJoyIdBackup.
enable(backups)Turns recovery on with these backups: preview, approval, emailed code. Resolves once the transaction is committed, with RecoveryEnabled (transactionHash, ckbAddress, recovery).
addBackup(proof)Adds a backup: preview, approval, emailed code. Resolves once committed, with BackupChanged.
removeBackup(backup)Removes a backup the same way. The last one can't be removed.

Approval props

KeyWayProvider shows its own dialogs. To use yours, pass:

PropReceives
approveRecoveryRecoveryPreview: newAddress, legacyAddress, backups (with a label each), adminListCkb, minimumCellCkb, tokenCellsExtraCkb, lockedCkb, feeCkb, movedCkb, tokens, cellsLeft, transactionHash, network.
approveBackupChangeBackupChangePreview: change (add or remove), backup, backups after the change, feeCkb, transactionHash, network.

Return true to go ahead or false to cancel. The emailed code follows either way and is always KeyWay's own prompt.

Known limits

  • Omnilock has no public audit. KeyWay pins it by data hash, so an upgrade can't change the code for KeyWay accounts. On mainnet the code cell is the official one, under its deployer's multisig: if they ever replaced it, the dep would be gone until anyone redeployed the same bytes. Funds would be frozen in that case, never stolen.
  • KeyWay's verifiers are unaudited. They are small and open source, referenced by data hash so they can't change, and their builds are reproducible.
  • A backup can move everything once the user signs the text. The text names the destination; a user who signs a move to someone else's address loses the funds. Choose backups you control, and read the address before signing.
  • Message signing still reports the Lit public key. signer.signMessage() signs with the account's secp256k1 key, so a verifier that derives an address from the signature gets the old address, not the Omnilock one.
  • Testnet only. The rescue page has a mainnet switch, prepared but untested, and the verifiers aren't deployed on mainnet.

On this page