CashNector

Signature-based wallet connection for Bitcoin Cash.

CashNector authenticates a wallet, then lets your dApp request signatures, payments, and transactions from it — all through your own server. No relay, no persistent connection, no third party.

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

LayerWhat it doesEndpoints
AuthProve the wallet controls an address/challenge, /verify, /session/:id
Signing extensionRequest 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

ComponentRoleRequired?
auth-handler.mjsChallenge / verify / session / signing endpoints on your serverYes
cashnector-auth.mjsBrowser SDK (CashNectorAuth) — QR, polling, signing requestsYes
Wallet integrationParse the URI, sign, POST; poll for signing requestsWallet 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);
});
The server also mints a 256-bit 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

  1. Challenge. The dApp server generates CashNect to {dappName}: {128-bit nonce} and stores it with a 5-minute, single-use TTL.
  2. QR. The cashnector: URI (challenge id, auth URL, challenge text) is rendered as a QR code.
  3. Sign. The wallet scans it, signs the challenge with its private key (BIP-137), and POSTs { challengeId, address, signature } to /verify.
  4. 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.

  1. dApp: POST /session/:id/request with a type and data → gets a requestId.
  2. Wallet: GET /session/:id/requests → sees the pending request, prompts the user.
  3. Wallet: POST /session/:id/respond with { requestId, result } (or { requestId, error }).
  4. 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

PhaseStateNotes
ChallengeStored, unauthenticated5-minute TTL, single-use
VerifySignature checkedChallenge consumed on success
AuthenticatedSession token issuedPersisted to sessions.json, 7-day TTL
SigningRequests / responsesUp 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

MethodReturnsDescription
createChallenge(){ challengeId, qrData, challenge }Request a challenge, build the QR URI, and start polling for auth
cancel()voidStop polling for the current challenge

Events

EventPayloadWhen
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
The 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

MethodReturnsDescription
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 objectPoll 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

In local development the browser calls the API at 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 })
});
The server verifies the signature against the challenge it stored under 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

OptionDefaultDescription
corsOrigins['*']Allowed CORS origins
port8097createAuthServer 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.

Three request types are supported: sign_transaction, send_payment, and sign_message. All ride on the authenticated session — identified by its challengeId.

The exchange

  1. dApp submitsPOST/cashnector/session/:id/request with { type, data }, receives { requestId }.
  2. Wallet pollsGET/cashnector/session/:id/requests returns pending requests.
  3. Wallet respondsPOST/cashnector/session/:id/respond with { requestId, result } or { requestId, error }.
  4. dApp readsGET/cashnector/session/:id/result/:requestId returns { requestId, type, status, response }.

Request status

StatusMeaning
pendingSubmitted, not yet answered by the wallet
completedWallet returned a result
rejectedWallet 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
}
A session holds at most 10 pending requests. Submitting an 11th returns 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 */ ]
});
Covenant-unlock requests can carry a full CashScript artifact in 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, mirrors eth_signTransaction) return only this. The dApp broadcasts it, then optionally calls notifyBroadcastCompleted.
  • txid — 64-char hex. Wallets that broadcast on their own return this (and may also include signedTransaction).

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
Backward compatible: wallets that broadcast their own transactions (returning txid) never need this loop, and old wallets that don't poll simply ignore it.

HTTP Endpoints

Every route under /cashnector.

Authentication

MethodPathPurpose
POST/cashnector/challengedApp requests a new challenge → { challengeId, challenge }
POST/cashnector/verifyWallet submits { challengeId, address, signature }
GET/cashnector/session/:iddApp polls for the auth result (SSE if Accept: text/event-stream)
DEL/cashnector/session/:idDisconnect and invalidate the session
GET/cashnector/healthHealth check + active challenge count
GET/cashnector/infoReturns { authUrl } for QR reachability

Signing extension

MethodPathPurpose
POST/cashnector/session/:id/requestdApp submits { type, data }{ requestId }
GET/cashnector/session/:id/requestsWallet polls for pending requests
POST/cashnector/session/:id/respondWallet posts { requestId, result } or { requestId, error }
GET/cashnector/session/:id/result/:requestIddApp polls for the request result

Broadcast loop

MethodPathPurpose
POST/cashnector/session/:id/broadcast-completeddApp reports a broadcast txid: { requestId, txid }
GET/cashnector/session/:id/broadcast-notificationsWallet 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

FieldTypeDescription
challengeIdhex (16 bytes)Unique challenge / session identifier
authUrlURL-encodedAuth base URL the wallet must reach (from externalAuthUrl or /info)
challengeURL-encoded stringThe 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...
Wallets detect the cashnector: prefix in their QR scanner, the same way they detect bitcoincash:.

Configuration

Server limits and defaults.

SettingValueNotes
Challenge TTL5 minutesSingle-use; consumed on successful verify
Max active challenges200Global cap; expired challenges swept every 30s
Max challenges per IP10Per-IP rate limit on /challenge
Max pending requests / session10Signing extension
Max request body1 MBFits full CashScript artifacts for covenant spends
Session persistencesessions.jsonAuthenticated sessions only, 7-day TTL, reloaded on start
Session token256-bitMinted at verify, returned by /session/:id
CORS*Configurable via corsOrigins

Errors

HTTP status codes returned by the handler.

StatusMeaning
400Missing/invalid fields, malformed JSON, invalid address format, or request body too large
401Invalid signature — recovery/derivation did not match the claimed address
404Challenge, session, or request not found (or session not authenticated)
409Challenge already used
429Rate 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.

Download CashNector

Open protocol. Self-host free. Drop into any BCH project.

SDK

Auth SDK Ready

Browser-side auth library for any BCH dApp. ES module.

Download

SDK Bundle Ready

Auth server + dApp SDK + QR helper. Everything in one .zip.

Download

Auth Server Ready

Minimal auth endpoint. Challenge/verify handler. Node.js.

Download
Package Managers

npm Coming Soon

npm install cashnector

Source

GitBCH Coming Soon

Full source on GitBCH. Clone, fork, contribute.

Looking for a wallet that supports CashNector?

AiNativeWallet.com →