Smart Account
Developer guide

Integrate the Smart Account

Let your CKB dApp ask a user to connect and to approve a transaction, signed by the passkey already on their device. One redirect out, one redirect back. No seed phrase, no wallet-specific signing code, and nothing about the underlying lock ever reaches your app.

Status: Pudge testnet, preview. This wallet and its connector run on the CKB Pudge testnet. The connector API shapes below are stable enough to build against, and ship with a popup/postMessage channel, a client package, and message signing (all covered below).

The wallet is built on CCC (CKBer's Codebase, the common JS/TS toolkit for CKB). Under the hood the account is a standard ccc.Signer, so you express what you want as an ordinary CKB transaction and the wallet does the rest: it picks the user's input cells, pays the fee, asks the user to approve with Face ID / Touch ID / Windows Hello, broadcasts, and hands you back the transaction hash.

How it works

Integration is a redirect handshake, the same shape as a hosted payment link:

  1. Your app opens the wallet at a deep link (#/connect or #/dapp), passing a callback URL back to your app.
  2. The user reviews the request and approves it with their passkey. Their private key never leaves their device, and never reaches you.
  3. The wallet redirects the browser back to your callback with the result appended as query parameters (an address, or a broadcast transaction hash).

Only two things ever cross the boundary into your app: the user's public CKB address and a transaction hash. No keys, no recovery details, no lock or contract internals.

The base URL

Every deep link targets the wallet app. Throughout this guide that is written as WALLET; substitute your deployment, for the public testnet instance the wallet app is served at /app/ on the Smart Account site. So a connect link is WALLET/app/#/connect?....

Quickstart

Connect, then ask the user to send 100 CKB to your address. Two redirects, a few lines each. Prefer to see it first? Open the live demo dApp. Note: the passkey approval step only runs when the wallet is served over HTTPS, the public preview is a plain-IP test host, so the redirect and transaction build work but the signing tap will not complete there yet.

// 1. Send the user to the wallet to connect. When they approve, the wallet
//    redirects to your callback with ?sa_status=connected&sa_address=ckt1...
const WALLET = "https://your-wallet-host";
function connect() {
  const cb = encodeURIComponent(location.href.split("?")[0]);
  location.href = `${WALLET}/app/#/connect?origin=My%20dApp&callback=${cb}`;
}

// 2. Read the result when the wallet returns to your page.
const p = new URLSearchParams(location.search);
if (p.get("sa_status") === "connected") {
  const userAddress = p.get("sa_address");
  // ...now build a transaction and ask the wallet to sign it (step 3).
}
// 3. Build the transaction you want and hand it to the wallet to sign + send.
import { ccc } from "@ckb-ccc/core";

const client = new ccc.ClientPublicTestnet();
const toScript = (await ccc.Address.fromString(myAddress, client)).script;

const tx = ccc.Transaction.from({
  outputs: [{ lock: toScript, capacity: ccc.fixedPointFrom(100) }],
  outputsData: ["0x"],
});
const txHex = ccc.hexFrom(tx.toBytes());

const cb = encodeURIComponent(location.href.split("?")[0]);
location.href = `${WALLET}/app/#/dapp?origin=My%20dApp&tx=${txHex}&callback=${cb}`;
// On return: ?sa_status=success&sa_tx=0x<hash>

That is the whole integration. You only ever describe the outputs you want; the wallet supplies the user's inputs and fee.

Client package

The fastest way to integrate is the smart-account-connect package. It wraps the deep links below in a popup + postMessage channel, so each call returns a Promise instead of navigating your page. The source ships in the repo's connect/ folder; publish it to npm to install by name.

import { SmartAccount } from "smart-account-connect";

const sa = new SmartAccount({ wallet: "https://your-wallet-host", appName: "My dApp" });

const { address }   = await sa.connect();              // share the CKB address
const { tx }        = await sa.requestTransaction(txHex); // sign + broadcast
const { signature } = await sa.signMessage("Login at 2026-06-24"); // prove control

Each call resolves to { status, address?, tx?, signature? }; branch on status. Popups can be blocked, so the package also exposes connectRedirect() / requestTransactionRedirect() / signMessageRedirect() (which navigate the page) plus readRedirectResult() to read the outcome on return. The sections below document the underlying deep-link contract the package speaks, in case you would rather not add a dependency.

Connect

Open the wallet to obtain the user's address. This authorizes nothing to move, it only shares a public address.

WALLET/app/#/connect?origin=<name>&callback=<url>
ParameterRequiredDescription
originrecommendedA human name for your app, shown to the user on the approval screen (max 80 chars).
callbackyesAn http(s) URL the wallet returns to. Other schemes are rejected.

On approval the wallet redirects to callback?sa_status=connected&sa_address=<ckb-address>. If the user declines, sa_status=rejected. If they have no wallet on the device yet, sa_status=no_wallet (the wallet shows a brief prompt telling the user to create or restore a wallet and reopen the link, and returns no_wallet to your app: it does not auto-resume the original request).

Request a transaction

Hand the wallet a serialized CKB transaction. It completes the inputs and fee from the user's account, shows them exactly how much leaves the account, signs with the passkey, and broadcasts.

WALLET/app/#/dapp?origin=<name>&tx=<tx-hex>&callback=<url>
ParameterRequiredDescription
txyesThe transaction as 0x hex (ccc.hexFrom(tx.toBytes())). Provide the outputs you want; leave inputs empty.
originrecommendedYour app name, shown on the review screen.
callbackyesAn http(s) URL to return to with the result.

On success: callback?sa_status=success&sa_tx=<hash>. If the user cancels the review, or the request is malformed: sa_status=rejected. (Treat your own timeout as a cancel too, in case the user simply closes the tab.)

The user always reviews. Before signing, the wallet shows the net CKB leaving the account and each destination. You cannot move funds without an explicit passkey approval from the user, so design your UX to expect a decline.

Sign a message

Ask the user to sign a text message to prove they control the account, for login, terms acceptance, or off-chain authorization. This is not a transaction and can never move funds.

WALLET/app/#/sign?origin=<name>&msg=<message>&callback=<url>
ParameterRequiredDescription
msgyesThe message to sign (URL-encoded). Shown to the user verbatim before they approve.
originrecommendedYour app name, shown on the approval screen.
callbackyesAn http(s) URL to return to with the result.

On approval: callback?sa_status=signed&sa_signature=<hex>&sa_address=<addr>. On decline: sa_status=rejected. The signature is the WebAuthn assertion field, laid out as authData_len(2) ‖ authData ‖ clientData_len(2) ‖ clientDataJSON ‖ pubkey(64) ‖ sig(64), zero-padded to a fixed 512 bytes (parse by the two embedded length prefixes and ignore the trailing zeros). Verify it like this:

  1. Parse the field into authData, clientDataJSON, pubkey, sig.
  2. Check clientDataJSON.challenge (base64url-decoded) equals SHA256("CKB Smart Account signed message:\n" + message).
  3. Verify the P-256 signature over SHA256(authData ‖ SHA256(clientDataJSON)) with pubkey.
  4. Bind it to the account: check blake160(pubkey) is one of the account's on-chain authenticators (read the Config Cell at the account's lock).
Domain separation. A message's challenge is SHA256(prefix + message), never a transaction sighash (which is a blake2b of transaction data). So a message signature can never be replayed as authorization to move funds, which is what makes it safe to sign arbitrary messages with the same passkey.

Building the transaction

You provide a transaction that describes where value should go (the outputs). You do not add inputs or a fee, the wallet fills those from the connected account when the user approves. This keeps your app stateless about the user's cells.

import { ccc } from "@ckb-ccc/core";
const client = new ccc.ClientPublicTestnet();

// pay 250 CKB to one address and attach a small data cell to another
const a = (await ccc.Address.fromString(addrA, client)).script;
const b = (await ccc.Address.fromString(addrB, client)).script;

const tx = ccc.Transaction.from({
  outputs: [
    { lock: a, capacity: ccc.fixedPointFrom(250) },
    { lock: b, capacity: ccc.fixedPointFrom(75) },
  ],
  outputsData: ["0x", "0x48656c6c6f"],
});
const txHex = ccc.hexFrom(tx.toBytes());

The connector auto-completes CKB capacity and the fee only. So plain-CKB payments and Nervos DAO deposits work by handing over outputs alone. Operations that consume existing typed cells, an xUDT transfer, a Spore / DOB update, need those typed inputs and their cell deps, so for those your dApp builds the fuller transaction (inputs + deps included) and hands that over; the wallet still signs it with the passkey. Anything expressible as a complete CCC transaction can be signed this way.

Using CCC directly (embedded apps)

If your app runs inside a trusted surface that already holds the account (for example a first-party flow rather than a third-party dApp), you can skip the redirect entirely: the account is a standard ccc.Signer, so CCC's own helpers drive it.

// the wallet exposes a ccc.Signer for the account; CCC does the rest
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer, 1000);
const hash = await signer.sendTransaction(tx);

The signer computes the standard CKB sighash-all, signs it with the device passkey, and writes the witness, so any CCC-based SDK works against it with no special handling. The redirect connector above is simply this same path, wrapped so a separate dApp can reach it safely.

Callback responses

Every response is appended to your callback URL as query parameters. Always branch on sa_status.

Flowsa_statusExtra parameters
Connectconnectedsa_address = the user's CKB address
Transactionsuccesssa_tx = the broadcast transaction hash
Sign messagesignedsa_signature = the WebAuthn assertion (hex), sa_address
Anyrejected(none) the user declined or the request was invalid
Anyno_wallet(none) no wallet on the device yet

A returned sa_tx means the transaction was broadcast, not yet confirmed. Wait for it on chain (CCC's client.waitTransaction(hash)) before treating it as final.

Security model

Limits & roadmap

Questions or want a flow that is not covered here? The wallet is CCC-native, so if you can build it as a CCC transaction, it can be signed, reach out and we will document the pattern.