Subfrost WalletConnect
Subfrost ships a custom WalletConnect-style protocol for letting a
webapp request signatures from the Subfrost mobile app over a relay.
It is not WalletConnect v2 (the public WC namespace is
EVM-centric and doesn't fit Bitcoin signing surfaces) — Subfrost runs
its own ChaCha20-Poly1305 + X25519 + custom JSON envelopes against a
relay at wss://wc.subfrost.io/.
The webapp-side client is at
subfrost-app/lib/wc/client.ts;
the mobile-side Rust crate is subfrost-mobile-wc exposed to the
Compose UI via subfrost-mobile-ffi::wc. This page documents the
protocol from the webapp integrator's perspective.
Architecture
┌─────────────┐ wss://wc.subfrost.io ┌─────────────────┐
│ Webapp │ ──── encrypted envelopes ───→ │ wc-relay │
│ (browser) │ ←──── encrypted envelopes ──── │ (Cloud Run + CF)│
└─────────────┘ └────────┬────────┘
▲ │
│ QR code FCM wake-push │
│ ▼
│ ┌──────────────────┐
│ ┌──── pair via QR scan ──────────────► │ Subfrost mobile │
└──┤ │ (Android) │
└──── encrypted responses ─────────────►└──────────────────┘
Three responsibilities:
- Pairing — a webapp generates an X25519 ephemeral keypair,
encodes its pubkey into a
subfrost://wc/<topic>?key=...URI, renders it as a QR. The mobile scans, derives the shared symmetric key via X25519+HKDF-SHA256, posts an "accept" message to the relay, returns. - Encrypted JSON envelopes — every request/response goes through
ChaCha20-Poly1305 with the per-pairing symmetric key. The relay
sees only
{ciphertext, nonce}blobs. - Live notifications — the relay uses Firebase Cloud Messaging to wake the mobile when a new request arrives. The mobile fetches pending requests via HTTP, decrypts, prompts the user, and posts the encrypted response back.
Quick start (webapp)
Install:
pnpm add @subfrost/wc
# or, if using the in-tree copy from subfrost-app:
# import from './lib/wc/client'
Connect:
import { connect } from '@subfrost/wc';
const { pairingUri, accepted, cancel } = await connect({
origin: window.location.origin,
// relayUrl defaults to 'wss://wc.subfrost.io/' — override for dev:
// relayUrl: 'wss://wc-staging.subfrost.io/',
});
// Render the pairing URI as a QR for the user to scan.
renderQrCode(pairingUri);
// pairingUri example:
// subfrost://wc/<topic-uuid>?key=<base64url-x25519-pub>
// &relay=wss://wc.subfrost.io/&origin=https://app.example.com
// Wait for the mobile to approve.
let session;
try {
session = await accepted; // resolves to a WcSession
} catch (err) {
console.error('pairing rejected:', err);
return;
}
// Now you can send sign requests.
const addrs = await session.getAccounts();
console.log('paired addresses:', addrs);
Sign a PSBT:
const signedHex = await session.signPsbt(unsignedPsbtHex, [
// optional address restriction — only sign for these.
'bc1q...',
]);
Sign an arbitrary message (BIP322 / Bitcoin Signed Message):
const sig = await session.signMessage(
'I authorize this swap at 2026-05-20T12:00:00Z',
'bc1q...',
);
Disconnect (revokes the relay row on both sides):
await session.disconnect();
Pairing URI format
subfrost://wc/<topic-uuid>?key=<base64url-x25519-pub>&relay=<wss-url>&origin=<https-url>
| Component | Required | Notes |
|-----------|----------|--------------------------------------------------------------|
| topic | yes | UUID-v4, unique per pairing |
| key | yes | Webapp X25519 public key (32 bytes, base64url, no padding) |
| relay | no | Defaults to wss://wc.subfrost.io/ (env-overridable on mobile) |
| origin | no | Webapp origin, displayed on the mobile pairing approval screen |
The mobile decodes key, derives the shared symmetric key:
priv_mobile = X25519::random()
ecdh = X25519(priv_mobile, key_webapp)
sym_key = HKDF-SHA256(ikm=ecdh, salt="subfrost-wc", info=topic, len=32)
Both sides MUST produce byte-identical sym_key (cross-checked by
subfrost-mobile-wc/tests/crypto.rs).
Wire envelope
Every request and response is a Plaintext JSON object encrypted with
the pairing's symmetric key:
type Plaintext =
| { type: 'sign_psbt'; psbt_hex: string; addresses: string[]; request_id: string; origin: string }
| { type: 'sign_message'; message: string; address: string; request_id: string; origin: string }
| { type: 'get_accounts'; request_id: string; origin: string }
| { type: 'result'; request_id: string; result: string }
| { type: 'error'; request_id: string; code: 'user_rejected' | 'permission_denied' | 'internal'; message: string }
| { type: 'accounts'; request_id: string; addresses: string[] };
The on-the-wire envelope:
{
"ciphertext": "<base64url ChaCha20-Poly1305 output>",
"nonce": "<base64url 12-byte nonce>",
"origin": "https://app.example.com",
"request_id": "<uuid v4>"
}
ciphertext is the AEAD output (includes 16-byte auth tag).
nonce is per-message and MUST NOT be reused with the same key.
Relay HTTP endpoints
The webapp client primarily uses HTTP POSTs and a WSS stream for push delivery. Endpoints below are documented for the curious; the client wraps them.
| Endpoint | Method | Description |
|-------------------------------------------|--------|------------------------------------------|
| /v1/sessions/<topic>/accept | POST | Mobile commits the pairing |
| /v1/sessions/<topic>/req | POST | Webapp sends an encrypted request |
| /v1/sessions/<topic>/pending | GET | Mobile fetches queued requests |
| /v1/sessions/<topic>/resp | POST | Mobile posts encrypted response |
| /v1/sessions/<topic> | DELETE | Either side revokes the pairing |
| /v1/sessions/<topic>/ws | WSS | Webapp WebSocket for live response delivery |
All endpoints accept the encrypted envelope shape above; the relay never inspects the plaintext.
Security model
- Relay is untrusted — it sees ciphertext, nonces, origins, and request ids. It cannot read or forge plaintext requests.
- Per-pairing keys — every QR pairing mints a fresh X25519 ephemeral keypair. The keypair lives only in the browser's memory on the webapp side and the mobile's secure storage on the mobile side. Refresh the browser tab → keypair is gone, session ends.
- No persistent session on the webapp — the webapp MUST re-pair if it loses its in-memory state. The mobile remembers paired origins and can re-issue sessions, but never auto-grants requests.
- Per-request mobile approval — every
sign_psbt/sign_messagerequest shows a confirmation screen on the phone. No batch approval, no "remember this site" auto-sign.
Differences from WalletConnect v2
| Aspect | WC v2 | Subfrost-WC |
|---------------------|-----------------------------|-----------------------------------------------|
| Namespace | CAIP-2 (EVM-centric) | Bitcoin native (sign_psbt, sign_message) |
| Pairing URI | wc://...@2?... | subfrost://wc/<topic>?key=... |
| Crypto | TweetNaCl box | X25519 + HKDF-SHA256 + ChaCha20-Poly1305 |
| Relay | relay.walletconnect.org | wc.subfrost.io (self-hosted) |
| Multi-method | yes (JSON-RPC envelope) | yes (typed Plaintext enum) |
| Push notifications | optional | required (FCM-only on Android) |
See also
- Webapp client source:
subfrost-app/lib/wc/ - Mobile crypto crate:
subfrost-mobile-wc - Mobile FFI:
subfrost-mobile-ffi/src/wc.rs - E2E test (headless):
subfrost-mobile/crates/subfrost-mobile-integ-tests/tests/wc_headless_e2e.rs