CashNector
Signature-based wallet connection for Bitcoin Cash.
What is CashNector?
CashNector is an open protocol that connects Bitcoin Cash wallets to dApps via cryptographic signatures. There is no relay server and no subscription. The dApp's own server is the only backend in the exchange.
Authentication is a challenge / sign / verify flow: the wallet signs a challenge message with its private key (BIP-137), and the server recovers the public key from the signature to prove address ownership. Once authenticated, the same session can carry signing requests — the dApp asks, the wallet responds.
Two layers
| Layer | What it does | Endpoints |
|---|---|---|
| Auth | Prove the wallet controls an address | /challenge, /verify, /session/:id |
| Signing extension | Request a signature, payment, or transaction from the connected wallet | /session/:id/request, /respond, /result/:rid |
Quick example
import { CashNectorAuth } from 'cashnector';
const cn = new CashNectorAuth({
dappName: 'My BCH dApp',
authUrl: '/cashnector'
});
const { qrData, challengeId } = await cn.createChallenge();
// Render qrData as a QR code
cn.on('authenticated', (session) => {
console.log('Wallet:', session.address);
// challengeId now doubles as the session id for signing requests
});
Architecture
| Component | Role | Required? |
|---|---|---|
auth-handler.mjs | Challenge / verify / session / signing endpoints on your server | Yes |
cashnector-auth.mjs | Browser SDK (CashNectorAuth) — QR, polling, signing requests | Yes |
| Wallet integration | Parse the URI, sign, POST; poll for signing requests | Wallet devs only |
Supported wallets
- AiNativeWallet — full integration (auth + signing extension) — AiNativeWallet.com →
- Any wallet with BIP-137 message signing and HTTP can integrate
Quick Start
Authenticate a wallet in your dApp in a few minutes.
1. Mount the auth handler
The handler runs on your server. It owns challenge generation, signature verification, session polling, and the signing extension.
// Node.js — mount under /cashnector
import { authHandler } from './lib/auth-handler.mjs';
const handler = authHandler();
// handler is an (req, res) function. Route every /cashnector/* request to it.
Or run it standalone:
import { createAuthServer } from './lib/auth-handler.mjs';
createAuthServer({ port: 8097 });
2. Add the browser SDK
<!-- QR code library -->
<script src="/lib/qrcode.min.js"></script>
<script type="module">
import { CashNectorAuth } from '/lib/cashnector-auth.mjs';
const cn = new CashNectorAuth({
dappName: 'Your dApp',
authUrl: '/cashnector'
});
</script>
3. Create a challenge, render the QR
async function connectWallet() {
const { qrData } = await cn.createChallenge();
const qr = qrcode(0, 'M');
qr.addData(qrData);
qr.make();
// ... render qr to a canvas
}
cn.on('authenticated', (session) => {
console.log(session.address); // verified BCH address
});
4. Use the session
The authenticated event carries the verified address and the challengeId. That same challengeId is the session identifier for any subsequent signing request.
cn.on('authenticated', async (session) => {
const id = session.challengeId;
// Ask the wallet to sign a message
const { requestId } = await cn.requestSign(id, 'sign_message', { message: 'gm' });
const result = await cn.waitForResult(id, requestId);
console.log(result.signature);
});
sessionToken at verification. It is returned by GET /cashnector/session/:id and can be used as a Bearer token for your own authenticated routes.How It Works
Challenge, sign, verify — then request and respond.
Authentication
- Challenge. The dApp server generates
CashNect to {dappName}: {128-bit nonce}and stores it with a 5-minute, single-use TTL. - QR. The
cashnector:URI (challenge id, auth URL, challenge text) is rendered as a QR code. - Sign. The wallet scans it, signs the challenge with its private key (BIP-137), and POSTs
{ challengeId, address, signature }to/verify. - Verify. The server recovers the public key from the signature, derives the CashAddr, and compares it to the claimed address. On match the challenge is consumed and a session token is issued.
Signing extension
After authentication, the session stays addressable by its challengeId. The dApp posts a signing request; the wallet — which is polling — picks it up, prompts the user, and posts back a response.
- dApp:
POST /session/:id/requestwith atypeanddata→ gets arequestId. - Wallet:
GET /session/:id/requests→ sees the pending request, prompts the user. - Wallet:
POST /session/:id/respondwith{ requestId, result }(or{ requestId, error }). - dApp:
GET /session/:id/result/:requestId→ reads the completed or rejected result.
Why this is different
A relay-based protocol keeps a persistent connection to a third-party server that every message passes through — a single point of failure, a metadata sink, and a billed service. CashNector uses your own server for a short challenge/verify exchange plus optional request polling. The wallet signs — no transaction, no on-chain cost for auth.
Session lifecycle
| Phase | State | Notes |
|---|---|---|
| Challenge | Stored, unauthenticated | 5-minute TTL, single-use |
| Verify | Signature checked | Challenge consumed on success |
| Authenticated | Session token issued | Persisted to sessions.json, 7-day TTL |
| Signing | Requests / responses | Up to 10 pending requests per session |
dApp SDK
CashNectorAuth — the browser library for connecting wallets to your dApp.
Constructor
const cn = new CashNectorAuth({
dappName: 'Your dApp', // display name shown in the wallet (optional)
authUrl: '/cashnector', // your auth endpoint (default '/cashnector')
dappUrl: 'https://your.app', // origin for relative authUrl (default location.origin)
externalAuthUrl: 'http://...', // LAN/public URL embedded in the QR (optional)
});
Authentication methods
| Method | Returns | Description |
|---|---|---|
createChallenge() | { challengeId, qrData, challenge } | Request a challenge, build the QR URI, and start polling for auth |
cancel() | void | Stop polling for the current challenge |
Events
| Event | Payload | When |
|---|---|---|
authenticated | { address, challengeId } | Wallet signature verified — address proven |
timeout | { challengeId } | No wallet signed within the polling window (~2 min) |
challenge_created | { challengeId, qrData } | Fired when the challenge and QR are ready |
authenticated payload does not include the server's sessionToken. Read it from GET /cashnector/session/:challengeId server-side if you need it as a Bearer token.Signing methods
| Method | Returns | Description |
|---|---|---|
requestSign(id, type, data) | { requestId } | Generic signing request. type is one of the three below |
requestTransaction(id, txData) | { requestId } | Shorthand for sign_transaction |
requestPayment(id, paymentData) | { requestId } | Shorthand for send_payment |
waitForResult(id, requestId, opts?) | the response object | Poll for the wallet's response. opts = { timeout=120000, interval=2000 }. Throws if rejected or timed out |
notifyBroadcastCompleted(id, requestId, txid) | { ok, error? } | Tell the wallet a sign-only TX was broadcast (see Broadcast Loop) |
There is no requestSign wrapper for sign_message; call the generic form: cn.requestSign(id, 'sign_message', { message }).
externalAuthUrl
location.origin (localhost), but the QR must embed a URL the phone can reach. Set externalAuthUrl, or fetch it from GET /cashnector/info — which returns { authUrl } derived from the request Host (real domain behind a proxy, LAN IP for direct dev access).Wallet SDK
For wallet developers integrating CashNector.
Auth flow
Parse the URI, sign the challenge, POST the result.
// When a scanned QR starts with "cashnector:"
const uri = parseUri(scannedUri);
// uri = { challengeId, authUrl, challenge }
// Sign the challenge with the wallet's private key (BIP-137)
const signature = await myWallet.signMessage(uri.challenge); // base64, 65-byte compact sig
const address = myWallet.cashaddr;
await fetch(uri.authUrl + '/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengeId: uri.challengeId, address, signature })
});
challengeId. You do not send the message back — only challengeId, address, and signature.Parsing the URI
function parseUri(uri) {
const body = uri.replace('cashnector:', '');
const [idPart, query] = body.split('?');
const [challengeId, authUrl] = idPart.split('@');
const params = new URLSearchParams(query);
return {
challengeId,
authUrl: decodeURIComponent(authUrl),
challenge: params.get('challenge')
};
}
Handling signing requests
To support the signing extension, poll for pending requests on the authenticated session and post responses.
// Poll for work (alongside your own UI loop)
const { requests } = await fetch(`${authUrl}/session/${id}/requests`).then(r => r.json());
for (const req of requests) {
// req = { requestId, type, data, status, createdAt }
const result = await handleInWalletUi(req); // prompt the user
await fetch(`${authUrl}/session/${id}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId: req.requestId, result })
// on user rejection: body: { requestId, error: 'User rejected' }
});
}
The wallet needs only two capabilities: BIP-137 message signing and HTTP. No persistent connection, no data channels.
Auth Server
The handler that runs on your dApp's server.
What it does
The handler owns challenge generation, signature verification (real secp256k1 recovery via @bitauth/libauth), session polling and SSE, the signing extension, and the broadcast loop-closer. It is your dApp's server — a set of endpoints under /cashnector.
Usage
// As a request handler — route /cashnector/* to it
import { authHandler } from './lib/auth-handler.mjs';
const handler = authHandler({ corsOrigins: ['*'] });
// As a standalone HTTP server
import { createAuthServer } from './lib/auth-handler.mjs';
createAuthServer({ port: 8097 });
Options
| Option | Default | Description |
|---|---|---|
corsOrigins | ['*'] | Allowed CORS origins |
port | 8097 | createAuthServer only — listen port |
Signature verification
Verification is a pure local computation — no external service. The handler decodes the 65-byte compact signature, recovers the public key with the correct compression from the recovery flag, hashes it to a CashAddr, and compares payloads with the claimed address. See the whitepaper, Section 4, for the full procedure.
Session persistence
Authenticated sessions are written to sessions.json next to the handler and reloaded on startup, with a 7-day TTL. Unauthenticated challenges live only in memory and expire after 5 minutes.
Signing Extension
Request signatures, payments, and transactions from a connected wallet.
sign_transaction, send_payment, and sign_message. All ride on the authenticated session — identified by its challengeId.The exchange
- dApp submits — POST
/cashnector/session/:id/requestwith{ type, data }, receives{ requestId }. - Wallet polls — GET
/cashnector/session/:id/requestsreturns pending requests. - Wallet responds — POST
/cashnector/session/:id/respondwith{ requestId, result }or{ requestId, error }. - dApp reads — GET
/cashnector/session/:id/result/:requestIdreturns{ requestId, type, status, response }.
Request status
| Status | Meaning |
|---|---|
pending | Submitted, not yet answered by the wallet |
completed | Wallet returned a result |
rejected | Wallet returned an error (e.g. user declined) |
SDK shape
const id = session.challengeId;
// Submit
const { requestId } = await cn.requestSign(id, 'sign_transaction', txData);
// Await the wallet
try {
const response = await cn.waitForResult(id, requestId, { timeout: 120000 });
// response shape depends on the request type
} catch (e) {
// thrown on rejection or timeout
}
429 until earlier ones are answered.sign_transaction Live
Ask the wallet to sign a transaction.
Request
cn.requestTransaction(id, txData) — or cn.requestSign(id, 'sign_transaction', txData). The data payload carries the transaction the wallet should sign (raw transaction hex or a wallet-connect-style transaction object with source outputs).
const { requestId } = await cn.requestTransaction(id, {
transaction: '0200000001...', // raw tx hex (or a structured WC transaction)
sourceOutputs: [ /* prevout details, incl. contract for covenant spends */ ]
});
sourceOutputs[i].contract. The handler accepts request bodies up to 1 MB for this reason.Response
{ "signedTransaction": "<hex>", "txid": "<hex64>" }
signedTransaction— hex of the signed raw TX. Wallets that delegate broadcast to the dApp (preferred, mirrorseth_signTransaction) return only this. The dApp broadcasts it, then optionally callsnotifyBroadcastCompleted.txid— 64-char hex. Wallets that broadcast on their own return this (and may also includesignedTransaction).
At least one of the two fields must be present; if neither is, treat the request as failed.
send_payment Live
Ask the wallet to build, sign, and broadcast a payment.
Request — multi-output Current
cn.requestPayment(id, paymentData). The current shape is multi-output: an array of outputs plus an optional memo. Amounts are in satoshis.
const { requestId } = await cn.requestPayment(id, {
outputs: [
{ address: 'bitcoincash:qq...', amountSats: 10000 },
{ address: 'bitcoincash:qz...', amountSats: 25000 }
],
memo: 'Order #4211'
});
Request — single-output Legacy
The older single-output shape is still accepted for backward compatibility. Prefer the multi-output shape above for new integrations.
{ "amount": 10000, "toAddress": "bitcoincash:qq...", "opReturn": "optional" }
Response
{ "txid": "<hex64>" }
The wallet builds, signs, and broadcasts the payment itself, then returns the resulting transaction id.
sign_message Live
Ask the wallet to sign an arbitrary text message.
Request
Use the generic form (there is no dedicated shorthand method):
const { requestId } = await cn.requestSign(id, 'sign_message', {
message: 'I agree to the terms at 2026-07-13T00:00:00Z'
});
Response
{ "signature": "<base64>" }
The signature is a BIP-137 compact signature over the supplied message, base64-encoded — the same format used by the authentication step.
Broadcast Loop Optional
Close the loop when the dApp broadcasts a sign-only transaction.
Why
When a wallet returns only signedTransaction (the sign-only path), the dApp owns broadcast. After the dApp lands the TX on chain, it tells the wallet the resulting txid so the wallet can render the transaction in its history.
dApp → wallet
// After your broadcast endpoint returns a txid
await cn.notifyBroadcastCompleted(id, requestId, txid);
// POST /cashnector/session/:id/broadcast-completed { requestId, txid }
The txid must be 64 hex characters. This is a one-way, best-effort notification: failures are non-fatal and should be logged, not thrown.
Wallet poll
// Wallet learns of dApp-side broadcasts
const { notifications } = await
fetch(`${authUrl}/session/${id}/broadcast-notifications`).then(r => r.json());
// notifications: [{ requestId, txid, completedAt }] — delivered once, then cleared
txid) never need this loop, and old wallets that don't poll simply ignore it.HTTP Endpoints
Every route under /cashnector.
Authentication
| Method | Path | Purpose |
|---|---|---|
| POST | /cashnector/challenge | dApp requests a new challenge → { challengeId, challenge } |
| POST | /cashnector/verify | Wallet submits { challengeId, address, signature } |
| GET | /cashnector/session/:id | dApp polls for the auth result (SSE if Accept: text/event-stream) |
| DEL | /cashnector/session/:id | Disconnect and invalidate the session |
| GET | /cashnector/health | Health check + active challenge count |
| GET | /cashnector/info | Returns { authUrl } for QR reachability |
Signing extension
| Method | Path | Purpose |
|---|---|---|
| POST | /cashnector/session/:id/request | dApp submits { type, data } → { requestId } |
| GET | /cashnector/session/:id/requests | Wallet polls for pending requests |
| POST | /cashnector/session/:id/respond | Wallet posts { requestId, result } or { requestId, error } |
| GET | /cashnector/session/:id/result/:requestId | dApp polls for the request result |
Broadcast loop
| Method | Path | Purpose |
|---|---|---|
| POST | /cashnector/session/:id/broadcast-completed | dApp reports a broadcast txid: { requestId, txid } |
| GET | /cashnector/session/:id/broadcast-notifications | Wallet polls for broadcast confirmations (delivered once) |
:id is the challengeId. Signing and broadcast routes require the session to be authenticated; otherwise they return 404.URI Scheme
The cashnector: QR format.
Format
cashnector:{challengeId}@{authUrl}?challenge={message}
Fields
| Field | Type | Description |
|---|---|---|
challengeId | hex (16 bytes) | Unique challenge / session identifier |
authUrl | URL-encoded | Auth base URL the wallet must reach (from externalAuthUrl or /info) |
challenge | URL-encoded string | The message the wallet signs: CashNect to {dappName}: {nonce} |
Example
cashnector:7a3f9c2e1b4d6a8e@http%3A%2F%2F192.168.1.5%3A8097%2Fcashnector?challenge=CashNect%20to%20GitBCH%3A%204f2a8c1d...
cashnector: prefix in their QR scanner, the same way they detect bitcoincash:.Configuration
Server limits and defaults.
| Setting | Value | Notes |
|---|---|---|
| Challenge TTL | 5 minutes | Single-use; consumed on successful verify |
| Max active challenges | 200 | Global cap; expired challenges swept every 30s |
| Max challenges per IP | 10 | Per-IP rate limit on /challenge |
| Max pending requests / session | 10 | Signing extension |
| Max request body | 1 MB | Fits full CashScript artifacts for covenant spends |
| Session persistence | sessions.json | Authenticated sessions only, 7-day TTL, reloaded on start |
| Session token | 256-bit | Minted at verify, returned by /session/:id |
| CORS | * | Configurable via corsOrigins |
Errors
HTTP status codes returned by the handler.
| Status | Meaning |
|---|---|
400 | Missing/invalid fields, malformed JSON, invalid address format, or request body too large |
401 | Invalid signature — recovery/derivation did not match the claimed address |
404 | Challenge, session, or request not found (or session not authenticated) |
409 | Challenge already used |
429 | Rate limit: too many challenges (per-IP or global), or too many pending requests |
Request-level failures inside the signing extension surface through the response: a rejected request has status: 'rejected' with response.error. The SDK's waitForResult throws in that case.
FAQ
Frequently asked questions.
Is CashNector free?
The protocol and SDK are free and open source — self-host for zero cost. A hosted service is planned for those who prefer managed infrastructure.
Do I need a relay server?
No. Your dApp's own server generates challenges, verifies signatures, and relays signing requests. There is no third-party relay and no persistent connection.
Can the wallet do more than log in?
Yes. After authentication the signing extension supports sign_transaction, send_payment, and sign_message over the same session.
Who broadcasts the transaction?
For send_payment, the wallet builds, signs, and broadcasts. For sign_transaction, the wallet may broadcast itself (returns txid) or hand back signedTransaction for the dApp to broadcast — then the dApp can close the loop with notifyBroadcastCompleted.
Which wallets support CashNector?
AiNativeWallet has full support. Any wallet with BIP-137 message signing and HTTP can integrate.
How is this different from WalletConnect?
WalletConnect routes traffic through centralized relay servers. CashNector uses signature-based auth through your own server — no relay, no subscription, no Ethereum infrastructure. See the whitepaper, Section 8.