Keelchain
Docs / Keel Wallet

Keel Wallet: non-custodial keys (decided 2026-09-09)

My requirement: no user private key on any server, ever. The user holds the key in a browser extension (later a mobile app); the marketplace becomes a front end that asks the wallet to sign. This document is the contract between the three parts:

Chain-side support: session keys (AuthorizeSessionKey / RevokeSessionKey) so order-book trading does not need a popup per click, and permissionless trade expiry so a stuck trade needs no operator key.

1. Keys and signatures (unchanged chain primitives)

2. Provider API — window.keel

Injected by the extension's content script on Keelchain's own sites and on localhost out of the box, and on any site the user enables from the wallet's settings (the browser asks for that origin once; no wallet release is needed per client). connect-keel-wallet.md is the integration guide. All methods return promises; rejections use { code, message } with codes USER_REJECTED, LOCKED, NO_ACCOUNT, NOT_CONNECTED, INVALID_REQUEST, WRONG_NETWORK.

interface KeelProvider {
  readonly isKeel: true;
  readonly version: string;                      // "1.0.0"

  /** Ask the user to connect this origin. Remembered per origin until disconnect. */
  connect(opts?: { network?: string }): Promise<{ address: string; network: string; chainId: number }>;
  disconnect(): Promise<void>;
  /** The connected account for this origin, or null (never prompts). */
  getAccount(): Promise<{ address: string; network: string; chainId: number } | null>;

  /** Sign an arbitrary message (login challenge). Popup shows the text. */
  signMessage(message: string): Promise<{ address: string; signature: string }>;

  /**
   * Sign a chain action. The popup decodes the action (SDK) and shows a
   * human-readable summary before the user approves. `envelope.signer`
   * must equal the connected address, else INVALID_REQUEST.
   */
  signAction(req: {
    envelope: { signer: string; nonce: number; chain_id: number; action: Action };
    /** Optional caller context shown above the decoded action. */
    context?: { title?: string; description?: string };
  }): Promise<{ signature: string; tx_id: string; signed: SignedAction }>;

  /** Sugar for signAction with `AuthorizeSessionKey`; the popup explains the scope and expiry. */
  authorizeSession(req: { key: string; scope: SessionScope[]; expires_at: number; nonce: number; chain_id: number }): Promise<{ signature: string; tx_id: string; signed: SignedAction }>;

  on(event: 'accountChanged' | 'disconnect' | 'networkChanged', handler: (payload: unknown) => void): () => void;
}
type SessionScope = 'markets' | 'p2p_manage';

Extension behaviour:

3. Marketplace API (the site talks to this)

Login and linking

Method Path Body → Result
POST /v1/auth/wallet/challenge {address} → {challenge, expiresAt}; challenge text: Keel login\naddress: <addr>\nnonce: <random>\nissued: <iso>
POST /v1/auth/wallet/login {address, challenge, signature} → the normal session {token, userId, role, walletAddress} for an address already bound to an account
POST /v1/auth/wallet/register {address, challenge, signature, email, country, referralCode?, attribution?} → 201 with the session; creates the account with the wallet as its credential (no password; the email is still required for notifications, verification and KYC) and binds the address immediately
POST /v1/auth/wallet/link logged-in email user; {address, signature} → binds the address to the account (starts migration for custodial users, see §5)

Signing requests (server needs a signature from the wallet)

The server keeps every existing flow (offers, trades, withdrawals, budget lock…). Where it used to sign with the stored key it now creates a signing request and waits for the browser.

Method Path Body → Result
GET /v1/wallet/signing/pending {requests: [{id, envelope, summary, createdAt, expiresAt}]} for the session user
POST /v1/wallet/signing/:id {signature} → {ok, txId}; the server verifies the signature against the envelope before use
POST /v1/wallet/signing/:id/reject {reason?} → {ok}; the waiting flow fails with SIGNATURE_REJECTED

Also pushed as in-app notification wallet.signing_request {id} so the site can react without polling; the site polls every 2 s as fallback while a request is open. A request expires after 180 s → the flow fails with SIGNATURE_TIMEOUT. summary is the server's human text (e.g. "Release 0.05 BTC of trade #123 to @bob") — the wallet ALSO decodes the action itself and shows both.

Session keys

Method Path Body → Result
POST /v1/wallet/session {scope: SessionScope[], ttlSecs} → {signingRequestId, key, expiresAt}: server generates the session keypair (stored encrypted; scope-limited, cannot move funds) and queues an AuthorizeSessionKey signing request
GET /v1/wallet/session {active: {key, scope, expiresAt} | null}
DELETE /v1/wallet/session queues RevokeSessionKey (or lets it expire)

While a session is active and in scope, the server signs order-book actions with it directly (no popup). Out-of-scope actions still go to the wallet.

Account mode

GET /v1/chain/info gains keyMode: 'custodial' | 'wallet' | 'none' and walletAddress.

4. Chain: session keys and expiry

5. Migration (custodial → wallet)

  1. User links a wallet (/v1/auth/wallet/link). Precondition: no open offers, trades, orders, or unlock queue on the custodial address; the site lists what to close first.
  2. Server, with the custodial key, signs one Transfer per asset of the full deposit balance to the wallet address, and the attester signs a fresh Attest with the current tier for the wallet address.
  3. Server marks the account wallet: chain_user_keys.address = wallet address, secret_enc = NULL, key_mode = 'wallet'. From now on every signature is a signing request. The custodial secret is gone.
  4. Every step is a chain tx id, shown in the Chain tab.

5b. In Keel and in your wallet (self-custody for bridged coins)

A user's Bitcoin and Tron can sit in one of two places, and the wallet shows both:

Settings → New deposits go to picks which address the Receive card shows: Keel (default) or your wallet. Nothing already deposited changes. The card carries the one warning that matters: coins in your wallet have no observer set behind them, so a lost phrase loses them.

The popup is five tabs: Home (address, balances in Keel, Receive), Send, Deposits (every incoming deposit and its stage), Wallet (coins in your wallet) and Settings.

Move to Keel (the Wallet tab): the background derives the key, builds the transaction to the account's vault deposit address (Bitcoin: a P2WPKH spend with change back to the own address, fee from the recommended rate, Max takes the fee from the amount; Tron: a TRX transfer or a TRC-20 transfer, which needs a little TRX at the own address for energy), signs it locally, broadcasts it through the public API and wipes the key. From there it is an ordinary deposit and the Deposits card follows it.

Move to your wallet: Send → Move to your wallet. The bridged assets are always listed with what you hold in Keel, the destination is your own address for that chain, locked, and Max sends everything. An ordinary withdrawal: escrow, observer batch, threshold signature, fee from the amount.

Self-custody is offered on the testnet and mainnet, where the wallet knows the public APIs; a custom network shows the Keel side only. Code: apps/wallet-extension/src/core/selfcustody.ts, checked against the live vault addresses in its tests.

6. What the operator still signs (its own keys, never users')

Attester (KYC tiers), param admin (fees), treasury (awards, referral payouts), house maker quotes, the trade-expiry sweep, session keys it is authorized to hold. These stay in the marketplace's KMS/env as today.

7. Status (2026-09-09)

Implemented and verified on the local testnet:

Known gaps: