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.
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:
- Your app opens the wallet at a deep link (
#/connector#/dapp), passing acallbackURL back to your app. - The user reviews the request and approves it with their passkey. Their private key never leaves their device, and never reaches you.
- The wallet redirects the browser back to your
callbackwith 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.
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>
| Parameter | Required | Description |
|---|---|---|
origin | recommended | A human name for your app, shown to the user on the approval screen (max 80 chars). |
callback | yes | An 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>
| Parameter | Required | Description |
|---|---|---|
tx | yes | The transaction as 0x hex (ccc.hexFrom(tx.toBytes())). Provide the outputs you want; leave inputs empty. |
origin | recommended | Your app name, shown on the review screen. |
callback | yes | An 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.)
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>
| Parameter | Required | Description |
|---|---|---|
msg | yes | The message to sign (URL-encoded). Shown to the user verbatim before they approve. |
origin | recommended | Your app name, shown on the approval screen. |
callback | yes | An 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:
- Parse the field into
authData,clientDataJSON,pubkey,sig. - Check
clientDataJSON.challenge(base64url-decoded) equalsSHA256("CKB Smart Account signed message:\n" + message). - Verify the P-256 signature over
SHA256(authData ‖ SHA256(clientDataJSON))withpubkey. - 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).
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.
| Flow | sa_status | Extra parameters |
|---|---|---|
| Connect | connected | sa_address = the user's CKB address |
| Transaction | success | sa_tx = the broadcast transaction hash |
| Sign message | signed | sa_signature = the WebAuthn assertion (hex), sa_address |
| Any | rejected | (none) the user declined or the request was invalid |
| Any | no_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
- Keys never leave the device. The account is authorized by a WebAuthn passkey held in the device's secure hardware; your app only ever receives an address and a tx hash.
- The user approves every spend. Each transaction request is reviewed and confirmed with a biometric tap. You cannot sign on the user's behalf.
- Callbacks are restricted to
http(s). The wallet refuses to redirect tojavascript:ordata:URLs, so the return cannot run script. - Verify on chain. Treat the returned hash as a claim until you have confirmed the transaction with your own node / RPC. Never grant value off a redirect parameter alone.
- Passkeys are bound to the wallet's domain. The user's credential belongs to the wallet's origin, not yours, which is exactly why your app never handles it.
Limits & roadmap
- Testnet only. Pudge.
- Popup or redirect. Both channels ship. The client package uses the popup +
postMessagepath so you do not hand-build deep links; the raw redirect is there for environments where popups are blocked. - Message signing is live. See Sign a message. It is domain-separated from transactions, so a signed message can never move funds.
- HTTPS required for passkeys. The wallet must be served over HTTPS on a stable domain for WebAuthn to work, so use the real deployment URL, not an IP.