did-beta-bch
v3.1.1
Published
Decentralised identity protocol binding BCH addresses to Nostr public keys
Maintainers
Readme
cashlet-did
A standalone, framework-agnostic library implementing the Cashlet DID protocol — a decentralised identity layer that cryptographically binds Bitcoin Cash (BCH) addresses to Nostr public keys.
Any BCH wallet or Nostr client can use this library to publish, resolve, and verify BCH–Nostr identity bindings without depending on the Cashlet extension.
This release is Cashlet DID v3.0.0, a breaking protocol update. V3 verifiers do not accept v1 or v2 bindings; existing identities must be rebound and republished.
Real-world use cases
BCH wallet — call buildBinding + publishToRelays once when a user sets up their identity. Anyone can then pay them by Nostr handle (resolveAddress('npub1...')) instead of exchanging addresses. Users never see the protocol.
Nostr client — call verifyIdentity when rendering a profile to show a cryptographically-proven "verified BCH address" badge. No issuing authority — the proof is in the signature.
Merchant checkout — call resolveByNip05('[email protected]') at payment time to resolve the current NIP-05 pubkey and its verified binding. The v3 resolver checks old-key authorization when following a cross-key rotation.
Passwordless login — issue a server-side one-time challenge, collect BCH and Nostr signatures, and verify them with verifyDualChallengeProof. The application must atomically consume challenges and apply its own authorization policy. verifyHttpAuth remains available for NIP-98 requests.
Install
npm install did-beta-bchCore usage
Pay by handle — one call
import { resolveAddress } from 'did-beta-bch'
const address = await resolveAddress('npub1...')
// Returns the BCH address published in the verified binding, or null
// Token support depends on the bound address and the wallet's payment flow
if (address) wallet.sendTo(address, amount)Resolve + verify
import { resolveIdentity, verifyIdentity } from 'did-beta-bch'
const identity = await resolveIdentity('npub1...')
if (identity) {
const result = await verifyIdentity(identity)
console.log(result.status) // "verified"
console.log(identity.bchAddress) // bound BCH address
}Build and publish a binding (wallet integration)
import {
buildBinding, bchSigningPayload, signBchMessage, nostrEventTemplate,
publishToRelays, DEFAULT_RELAYS,
} from 'did-beta-bch'
import { finalizeEvent } from 'nostr-tools'
const binding = await buildBinding(bchAddress, 'mainnet', nostrPubkeyHex)
const message = bchSigningPayload(binding)
// Pass the exact text to a transport adapter; do not pre-hash or add a prefix.
const signatureBase64 = await signBchMessage(walletSigner, message)
const event = finalizeEvent(
nostrEventTemplate(binding, { message, signatureBase64 }),
nostrPrivkey,
)
await publishToRelays(DEFAULT_RELAYS, event)BchMessageSigner is transport-neutral:
const walletSigner = {
signMessage: (message: string) => walletAdapter.signMessage({ message }),
}signBchMessage passes the exact string to signMessage and strictly validates
the standard 65-byte Bitcoin compact-message signature returned as Base64.
The response must use a compressed-key header byte from 31 through 34. Paytaca's
published WalletConnect implementation and the BCH WalletConnect BCR match this
contract; test each wallet version you integrate. The library does not import
WalletConnect or WizardConnect and does not provide their transport adapters.
For WalletConnect, the adapter maps this call to bch_signMessage with the
message in its params. WizardConnect needs a separate wallet-side signing action
and adapter; neither is implemented in this package.
The signer is supplied by the integrating wallet, so this library does not require users to connect to Selene, Paytaca, or any other external wallet. A wallet can pass its own non-custodial BCH signer directly. For example, Cashlet uses its own BCH keys by default; its optional WalletConnect adapter is only for users who choose to bind an address held by another compatible wallet. In either case, the BCH key signs the proof while a separate Nostr key signs the published event.
Key derivation (NIP-06)
import { deriveNostrKeypair } from 'did-beta-bch'
const { privkey, pubkey, npub, nsec } = deriveNostrKeypair(mnemonic)
// account index for multiple independent identities from one seed
const social = deriveNostrKeypair(mnemonic, 1)Challenge-response authentication
import {
bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof,
} from 'did-beta-bch'
const payload = bchChallengePayload(nonce, audience)
const nostrTemplate = nostrChallengeTemplate(
nonce, audience, 'https://app.example/api/auth/verify', issuedAt,
)
// Collect the BCH signature over `payload` and Nostr signature over `nostrTemplate`.
const valid = verifyDualChallengeProof(
{ signature, payload, address, nostrEvent },
{
nonce,
audience,
endpoint: 'https://app.example/api/auth/verify',
issuedAt,
address,
pubkey,
},
)Challenge issuance and one-time consumption belong on a server with durable, atomic storage. Do not treat browser-only challenge state as authentication.
Key rotation
import {
buildBinding, rotationAuthorizationTemplate, nostrEventTemplate,
resolveIdentityChain,
} from 'did-beta-bch'
// For a changed Nostr key, have the old key sign this authorization event.
const newBinding = await buildBinding(newAddress, 'mainnet', newNostrPub, undefined, oldBindingId)
const authorizationTemplate = rotationAuthorizationTemplate(oldBinding, newBinding)
const oldKeyAuthorization = finalizeEvent(authorizationTemplate, oldNostrPrivkey)
// Publish the successor with its BCH proof and the old-key authorization.
const successorTemplate = nostrEventTemplate(newBinding, bchProof, oldKeyAuthorization)
// Resolve linked, individually verified binding events, newest first
const chain = await resolveIdentityChain('npub1...')For cross-key rotation, the resolver verifies the old-key authorization and
requires every supersedes target to be present, unrevoked, and valid. Lost-key
recovery is not supported.
Verifiable credentials + reputation
import { issueCredential, resolveCredentials, issueRating, scoreReputation } from 'did-beta-bch'
// Issue a credential (issuer signs with their Nostr key)
const { credential } = await issueCredential(subjectNpub, 'merchant', issuerPrivkey, { since: '2024' })
// Fetch + score reputation for an npub
const creds = await resolveCredentials('npub1...')
const score = await scoreReputation('npub1...')resolveCredentials verifies the issuer's Nostr event signature, the subject
and credential tags, and any expiry. Applications must still decide which
issuers and credential types they trust; scoreReputation is a heuristic, not
an objective or Sybil-resistant trust measure.
Selective disclosure
import { buildDisclosure, verifyDisclosure } from 'did-beta-bch'
// Commit to attributes; store commitments with the identity record separately
const d = await buildDisclosure({ merchant: 'true', country: 'AU', kyc: 'passed' })
// Later: reveal selected values and their salts; hidden salts stay private
const revealed = d.reveal(['merchant'])
const { verified, failed } = await verifyDisclosure(revealed, d.commitments)Commitments use random per-attribute salts to prevent offline guessing of low-entropy values until those values are revealed. This helper does not publish commitments or attach them to a binding event automatically.
BCH-anchored identity commitment
import {
buildAnchorTx, verifyBchAnchorProof,
BCH_MAINNET_CHECKPOINT, BCH_MAINNET_ASERT_PARAMETERS,
BCH_CHIPNET_CHECKPOINT, BCH_CHIPNET_ASERT_PARAMETERS,
broadcastAnchorTx, fetchAnchorProof, wsTransport,
ELECTRUM_MAINNET_WS,
} from 'did-beta-bch'
// Build and broadcast
const { txHex } = await buildAnchorTx(bindingId, { txid, vout, valueSats, lockingBytecode, privateKey })
const transport = wsTransport(ELECTRUM_MAINNET_WS)
const txid = await broadcastAnchorTx(txHex, transport)
// Fetch proof and verify (after confirmation)
const proof = await fetchAnchorProof(txid, BCH_MAINNET_CHECKPOINT.height, wsTransport(ELECTRUM_MAINNET_WS))
const result = verifyBchAnchorProof(proof, bindingId, {
checkpoint: BCH_MAINNET_CHECKPOINT,
asert: BCH_MAINNET_ASERT_PARAMETERS,
minConfirmations: 6,
})wsTransport works in both browser and Node.js (Node 22+ has native WebSocket). For Node < 22 or TCP, implement the three-method ElectrumTransport interface.
Chipnet uses BCH_CHIPNET_CHECKPOINT and BCH_CHIPNET_ASERT_PARAMETERS. Note chipnet is a test network that may reset; the checkpoint is valid for the current chain instance only.
BIP47 payment code address derivation
import { parsePaymentCode, derivePaymentAddress } from 'did-beta-bch'
import { deriveHdPrivateNodeFromBip39Mnemonic, deriveHdPath, deriveHdPublicNode } from '@bitauth/libauth'
// Decode a recipient's payment code
const { publicKey, chainCode } = parsePaymentCode('PM8T...')
// Derive the recipient's HD public node from their payment code
// (or supply it directly if you have it from your own derivation)
const recipientNode = deriveHdPublicNode(deriveHdPath(recipientRoot, "m/47'/0'/0'"))
// Sender: derive the nth payment address to send to
const senderScanPrivkey = deriveHdPath(senderRoot, "m/47'/0'/0'/0").privateKey
const address = await derivePaymentAddress(senderScanPrivkey, recipientNode, 0)
wallet.sendTo(address, amount)derivePaymentAddress implements BIP47 v1 ECDH address derivation. Notification transactions and spending-side key derivation are wallet-specific and out of scope for this library.
NIP-05 handle resolution
import { resolveByNip05 } from 'did-beta-bch'
// [email protected] → npub → verified BCH address
const address = await resolveByNip05('[email protected]')
if (address) wallet.sendTo(address, amount)NIP-98 HTTP Auth (server-side)
import { buildHttpAuthEventAsync, verifyHttpAuth } from 'did-beta-bch'
// Client: build + sign auth event
const template = await buildHttpAuthEventAsync('https://api.example.com/me', 'GET')
const event = await window.nostr.signEvent(template) // NIP-07 browser extension
const header = 'Nostr ' + btoa(JSON.stringify(event))
// → send as Authorization header
// Server: verify — proves Nostr identity + optionally BCH address ownership
const result = await verifyHttpAuth(header, 'https://api.example.com/me', 'GET', { body: null, verifyBch: true })
if (result.valid) {
console.log(result.pubkey) // verified Nostr pubkey
console.log(result.bchAddress) // verified BCH address (if verifyBch: true)
}NIP-89 App handler (advertise cashlet-did support)
import { buildAppHandlerEvent, publishToRelays, DEFAULT_RELAYS } from 'did-beta-bch'
import { finalizeEvent } from 'nostr-tools'
// Advertise that your app handles cashlet-did (kind 30078) events
const event = finalizeEvent(
buildAppHandlerEvent('My App', 'https://myapp.com', 'BCH + Nostr identity'),
nostrPrivkey,
)
await publishToRelays(DEFAULT_RELAYS, event)Protocol
Bindings are published as Nostr events of kind 30078 (NIP-78 addressable data):
| Tag | Value |
|---|---|
| d | binding ID (SHA-256 of canonical binding JSON) |
| bch | BCH address, network |
| bch-proof | ["bch-proof", "bitcoin-message/compact-base64", message, signatureBase64] |
| bch-payment-code | Optional BIP47 payment-code metadata; this library does not derive fresh addresses |
| protocol | cashlet-did, version |
| supersedes | previous binding ID (key rotation) |
| t | cashlet-did |
The wallet receives the exact UTF-8 v3 message text and applies the Bitcoin
Signed Message prefix, CompactSize byte length, and double-SHA256. Its response
is standard padded Base64 for a 65-byte compact signature: header 31–34
(compressed key plus recovery ID 0–3), followed by 64-byte r || s. V3
verifiers strictly reject v1/v2 and hex/raw signature encodings. Revocation uses
NIP-09 kind 5 deletion events.
The local demo previews and verifies a binding but does not publish or query
Nostr relays; /resolve needs a published binding and relay access.
Relays
Default relays: wss://nos.lol, wss://relay.damus.io, wss://relay.nostr.band
Pass a custom relay list to any function that accepts relays?: string[].
License
MIT
