{"_id":"@bsv/webrtpay","_rev":"4-e8c77eddcf1a9ec5b5f2b7c395f63305","name":"@bsv/webrtpay","dist-tags":{"latest":"0.2.1"},"versions":{"0.0.0-stage":{"name":"@bsv/webrtpay","version":"0.0.0-stage","_id":"@bsv/webrtpay@0.0.0-stage","stub":true,"maintainers":[{"name":"dkellen","email":"darrkellen@gmail.com"}],"dist":{"shasum":"f0fddcd3328ab3dbd7f1738f8d4b003bedcebcd1","tarball":"https://registry.npmjs.org/@bsv/webrtpay/-/webrtpay-0.0.0-stage.tgz","fileCount":2,"integrity":"sha512-RZ7JNBv+mtaAPmjhJlaoRpC7nTNKdiWwTiHVN8pNVgo48exdEcyn14Ts4uzPDko8s0BbvqL0KgcdRzVQXhOopg==","signatures":[{"sig":"MEUCIDPTxDItgsD5uYLZoUfeYGDWZ8kbAf82yWuJVTwBclUBAiEAqMKFWn+r1GFe2jwVf4c6+J9TS96YAe14CMEyBZm41Qg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":335},"_npmUser":{"name":"dkellen","email":"darrkellen@gmail.com"},"description":"Temporary package placeholder for staged publishing","directories":{}},"0.2.0":{"name":"@bsv/webrtpay","version":"0.2.0","keywords":["bsv","webrtc","p2p","payments","brc-100","peerpay"],"author":{"name":"BSV Association"},"license":"SEE LICENSE IN LICENSE.txt","_id":"@bsv/webrtpay@0.2.0","maintainers":[{"name":"johngalt5","email":"dylan@murraydt.com"},{"name":"loftsteinn","email":"oli@oskarsson.nl"},{"name":"dkellen","email":"darrkellen@gmail.com"},{"name":"braydude","email":"brayden167@gmail.com"},{"name":"tyeverett","email":"p2ppsr@tyweb.us"}],"homepage":"https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/webrtpay#readme","bugs":{"url":"https://github.com/bsv-blockchain/ts-stack/issues"},"bin":{"webrtpay-relay":"dist/server/cli.js"},"dist":{"shasum":"d52303e7d4ea05c8e21a245db95ced2651581492","tarball":"https://registry.npmjs.org/@bsv/webrtpay/-/webrtpay-0.2.0.tgz","fileCount":75,"integrity":"sha512-htiTjNr1at8Eg04CNphMCj1dc09Yr4OaxTY3RI1SUskMmb6E63YbwO9lfMfVNlTreuGxaIqEmGJTq9KSimAnEA==","signatures":[{"sig":"MEUCIGeLZ4ILi4fozAxApHPCy4ekB7WXt5dp8id2hLwj6IasAiEA030USJ0jA3WLtwlrcTQmRoJEZkULYDLQod+bmSv8iJ8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQCglOQ6kQis4hQ1sHSjDkwIAZEyW+MZmnB3LyoF1fMLNgIgECJCMB3HMKQ6OS06ds+sE5RjAJyW+MMzzEmkemEygNM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":577012},"main":"./dist/src/index.js","type":"module","_from":"file:bsv-webrtpay-0.2.0.tgz","types":"./dist/src/index.d.ts","engines":{"node":">=22"},"exports":{".":{"types":"./dist/src/index.d.ts","import":"./dist/src/index.js","default":"./dist/src/index.js"},"./server":{"types":"./dist/server/index.d.ts","import":"./dist/server/index.js","default":"./dist/server/index.js"},"./package.json":"./package.json"},"scripts":{"lint":"oxlint src server test examples --deny-warnings","test":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false","build":"tsc -p tsconfig.build.json","test:turn":"RUN_TURN_TESTS=1 node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false","typecheck":"tsc --noEmit","pack:check":"pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --allow-source-prefixes examples --exports DirectPayClient,PeerLink,authenticate,decodeCode,encodeOfferCode,encodeAnswerCode,acceptDirectPayment,verifyDirectPayment,validatePaymentToken,MessageBoxSignaler,RelaySignaler,createMemoryHub,WebRTPayError --entry-exports \"./server=createRelayServer|turnCredentials|DEFAULT_RELAY_LIMITS\" --bin webrtpay-relay --bin-args --help","format:check":"pnpm --workspace-root exec prettier --check \"packages/messaging/webrtpay/**/*.{json,md,ts}\"","test:browser":"pnpm build && node ../../../scripts/check-browser-package.mjs .","test:coverage":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false","test:property":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false test/codec.property.test.ts test/frames.property.test.ts"},"_npmUser":{"name":"dkellen","email":"darrkellen@gmail.com"},"_resolved":"/private/var/folders/nh/7h45bt091150f38kt6t1r9vr0000gp/T/a1dce5c29a16d43368204cf221ad4a46/bsv-webrtpay-0.2.0.tgz","_integrity":"sha512-htiTjNr1at8Eg04CNphMCj1dc09Yr4OaxTY3RI1SUskMmb6E63YbwO9lfMfVNlTreuGxaIqEmGJTq9KSimAnEA==","repository":{"url":"git+https://github.com/bsv-blockchain/ts-stack.git","type":"git","directory":"packages/messaging/webrtpay"},"_npmVersion":"12.0.1","description":"Direct wallet-to-wallet BSV payments over WebRTC data channels, authenticated with BRC-100 identity keys","directories":{},"sideEffects":false,"_nodeVersion":"24.15.0","dependencies":{"ws":"^8.21.3"},"publishConfig":{"access":"public"},"typesVersions":{"*":{"server":["dist/server/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"jest":"^30.5.1","oxlint":"^1.83.0","ts-jest":"^29.4.12","@bsv/sdk":"^3.2.1","@types/ws":"^8.18.1","fast-check":"^4.9.0","typescript":"npm:@typescript/typescript6@6.0.2","@types/jest":"^30.0.0","@types/node":"^26.6.1","@jest/globals":"^30.5.1","node-datachannel":"^0.33.4","@typescript/native":"npm:typescript@7.0.2","@bsv/message-box-client":"^2.6.0"},"peerDependencies":{"@bsv/sdk":"^2.8.6 || ^3.0.0"},"peerDependenciesMeta":{"@bsv/sdk":{"optional":false}},"_npmOperationalInternal":{"tmp":"tmp/webrtpay_0.2.0_1792433744297_0.2851639590053425","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"_id":"@bsv/webrtpay@0.2.1","bin":{"webrtpay-relay":"dist/server/cli.js"},"bugs":{"url":"https://github.com/bsv-blockchain/ts-stack/issues"},"dist":{"shasum":"b0ccd7e80f31b710e246d967397040c632f40673","tarball":"https://registry.npmjs.org/@bsv/webrtpay/-/webrtpay-0.2.1.tgz","fileCount":75,"integrity":"sha512-s5QOcy8iaqfN3sb+W0RUp1ulLBOJ3f/61XeMYCvH8svSG7mhWUufha2/KAceaBi+sojBHKLM2x0lOOR9XtIGNw==","signatures":[{"sig":"MEUCIBH5J4QK3XY3xyiHEMTvFBSiN7N7oUo6WsJCnAMG0cB0AiEAkEx41wbxOntJGDSS+B/Yez9P9IcDrjirU7vtVs1Hvw0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCyUP1LrI21iGwOzhcPxG0bgTsX34QnZ7ZDmEdmo/Xn/AIhAI8ZSPj18d5L3qHsqbJNOBd025ZWBOnAd+r1GN7e4xw+"}],"unpackedSize":583114},"main":"./dist/src/index.js","name":"@bsv/webrtpay","type":"module","_from":"file:bsv-webrtpay-0.2.1.tgz","types":"./dist/src/index.d.ts","author":{"name":"BSV Association"},"engines":{"node":">=22"},"exports":{".":{"types":"./dist/src/index.d.ts","import":"./dist/src/index.js","default":"./dist/src/index.js"},"./server":{"types":"./dist/server/index.d.ts","import":"./dist/server/index.js","default":"./dist/server/index.js"},"./package.json":"./package.json"},"license":"SEE LICENSE IN LICENSE.txt","scripts":{"lint":"oxlint src server test examples --deny-warnings","test":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false","build":"tsc -p tsconfig.build.json","test:turn":"RUN_TURN_TESTS=1 node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false","typecheck":"tsc --noEmit","pack:check":"pnpm build && node ../../../scripts/check-package-artifact.mjs . --modes esm --allow-source-prefixes examples --exports DirectPayClient,PeerLink,authenticate,decodeCode,encodeOfferCode,encodeAnswerCode,acceptDirectPayment,verifyDirectPayment,validatePaymentToken,MessageBoxSignaler,RelaySignaler,createMemoryHub,WebRTPayError --entry-exports \"./server=createRelayServer|turnCredentials|DEFAULT_RELAY_LIMITS|relayOptionsFromEnv|relayUsage\" --bin webrtpay-relay --bin-args --help","format:check":"pnpm --workspace-root exec prettier --check \"packages/messaging/webrtpay/**/*.{json,md,ts}\"","test:browser":"pnpm build && node ../../../scripts/check-browser-package.mjs .","test:coverage":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --watchman=false","test:property":"node --disable-warning=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --watchman=false test/codec.property.test.ts test/frames.property.test.ts"},"version":"0.2.1","_npmUser":{"name":"dkellen","email":"darrkellen@gmail.com"},"homepage":"https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/webrtpay#readme","keywords":["bsv","webrtc","p2p","payments","brc-100","peerpay"],"_resolved":"/private/var/folders/nh/7h45bt091150f38kt6t1r9vr0000gp/T/ec47bd677c2e564400108a9d8a45e80d/bsv-webrtpay-0.2.1.tgz","_integrity":"sha512-s5QOcy8iaqfN3sb+W0RUp1ulLBOJ3f/61XeMYCvH8svSG7mhWUufha2/KAceaBi+sojBHKLM2x0lOOR9XtIGNw==","repository":{"url":"git+https://github.com/bsv-blockchain/ts-stack.git","type":"git","directory":"packages/messaging/webrtpay"},"_npmVersion":"12.0.1","description":"Direct wallet-to-wallet BSV payments over WebRTC data channels, authenticated with BRC-100 identity keys","directories":{},"maintainers":[{"name":"johngalt5","email":"dylan@murraydt.com"},{"name":"loftsteinn","email":"oli@oskarsson.nl"},{"name":"dkellen","email":"darrkellen@gmail.com"},{"name":"braydude","email":"brayden167@gmail.com"},{"name":"tyeverett","email":"p2ppsr@tyweb.us"}],"sideEffects":false,"_nodeVersion":"24.15.0","dependencies":{"ws":"^8.21.3"},"publishConfig":{"access":"public"},"typesVersions":{"*":{"server":["dist/server/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"jest":"^30.5.1","oxlint":"^1.83.0","ts-jest":"^29.4.12","@bsv/sdk":"^3.2.1","@types/ws":"^8.18.1","fast-check":"^4.9.0","typescript":"npm:@typescript/typescript6@6.0.2","@types/jest":"^30.0.0","@types/node":"^26.6.1","@jest/globals":"^30.5.1","node-datachannel":"^0.33.4","@typescript/native":"npm:typescript@7.0.2","@bsv/message-box-client":"^2.6.0"},"peerDependencies":{"@bsv/sdk":"^2.8.6 || ^3.0.0"},"peerDependenciesMeta":{"@bsv/sdk":{"optional":false}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/webrtpay_0.2.1_1792442349181_0.015477274066240554"}}},"time":{"created":"2026-10-19T18:14:26.992Z","modified":"2026-10-19T20:39:09.517Z","0.0.0-stage":"2026-10-19T18:14:26.992Z","0.2.0":"2026-10-19T18:15:44.443Z","0.2.1":"2026-10-19T20:39:09.283Z"},"bugs":{"url":"https://github.com/bsv-blockchain/ts-stack/issues"},"author":{"name":"BSV Association"},"license":"SEE LICENSE IN LICENSE.txt","homepage":"https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/webrtpay#readme","keywords":["bsv","webrtc","p2p","payments","brc-100","peerpay"],"repository":{"url":"git+https://github.com/bsv-blockchain/ts-stack.git","type":"git","directory":"packages/messaging/webrtpay"},"description":"Direct wallet-to-wallet BSV payments over WebRTC data channels, authenticated with BRC-100 identity keys","maintainers":[{"name":"johngalt5","email":"dylan@murraydt.com"},{"name":"loftsteinn","email":"oli@oskarsson.nl"},{"name":"dkellen","email":"darrkellen@gmail.com"},{"name":"braydude","email":"brayden167@gmail.com"},{"name":"tyeverett","email":"p2ppsr@tyweb.us"}],"readme":"# @bsv/webrtpay\n\nDirect wallet-to-wallet BSV payments over WebRTC data channels, authenticated with BRC-100\nidentity keys.\n\nA payer hands the payee the same `PaymentToken` that PeerPay sends through MessageBox (Atomic\nBEEF, as a `number[]` or a `Uint8Array` going in and a `number[]` coming out, plus the BRC-29\nderivation prefix and suffix), but over an encrypted peer-to-peer data channel: no server\nstores it or can read it (when two networks need a TURN relay, the relay carries only DTLS\nciphertext). Both sides prove their identity key bound to the DTLS session, so whoever carries\nthe signaling cannot read, change or redirect a payment. A carrier can still drop signals,\nwhich stops a direct payment from happening (a denial of service), but it can never take or\ndivert the funds: the payer connects before it creates the transaction.\n\n- Browser-safe root entry: no Node builtins; `RTCPeerConnection` from the platform or injected\n  (node-datachannel in Node, react-native-webrtc in React Native).\n- `@bsv/sdk` is the only (peer) dependency of the root entry. `@bsv/webrtpay/server` (Node) adds\n  a small relay and depends on `ws`.\n- Status: 0.2.1, experimental. The byte-level protocol is in [docs/protocol.md](docs/protocol.md).\n\n## Why there is still a signaling step\n\nSTUN servers cannot coordinate two parties into a WebRTC channel. STUN (RFC 8489) only tells a\nclient its own public address; it carries no messages between parties. To open a channel each\nside needs the other's ICE username fragment and password, DTLS certificate fingerprint and\ncandidate addresses (RFC 8445 §5.3, §7.2.2), which is the SDP offer and answer, and they have\nto travel **both ways** over a channel that already works. TURN (RFC 8656) relays the data\nwhen two NATs cannot be punched through; it does not carry signaling either. A peer that never\nreceives the answer stays in `checking` forever, and the answerer gives up after about 15 s\n(Chromium) or 31 s (libdatachannel).\n\nSo the **payment** never needs a server, but the two SDPs (about 1 KB, encrypted) need a\ncarrier. This package makes the carrier a plug-in:\n\n- in person, two QR codes (`webrtpay1:` offer and answer codes), no server at all;\n- remotely, by identity key, through MessageBox or a small relay (`@bsv/webrtpay/server`).\n\n## Install\n\n```sh\nnpm install @bsv/webrtpay @bsv/sdk\n```\n\nRequirements: Node 22 or later, or a browser; `@bsv/sdk` `^2.8.6 || ^3.0.0`, a required peer\ndependency; a BRC-100 wallet\n(`WalletClient`, a wallet-toolbox `Wallet`, anything with the methods in `WalletLike`). In Node,\ninject a WebRTC implementation such as `node-datachannel/polyfill` through `rtc`.\n\n- **ESM only.** Use `import`; a CommonJS `require()` of the package needs Node 22.12 or later.\n- **DOM types.** The type declarations use the DOM's WebRTC types (`RTCPeerConnection`,\n  `RTCIceServer`, `RTCIceTransportPolicy`). A Node or React Native project that type-checks\n  them needs `\"lib\": [\"ES2022\", \"DOM\"]` in its `tsconfig.json`, or declarations of its own for\n  those names; with `skipLibCheck` and neither, they silently become `any`.\n\n## Quick start\n\n### Receive (payee)\n\n```ts\nimport {\n  DirectPayClient,\n  MessageBoxSignaler,\n  WebRTPayError,\n  acceptDirectPayment,\n  verifyDirectPayment\n} from '@bsv/webrtpay'\n\nconst originator = 'wallet.example.com'\nconst client = new DirectPayClient({\n  wallet, // a BRC-100 wallet\n  originator,\n  signaler: new MessageBoxSignaler(messageBoxClient), // a @bsv/message-box-client client\n  rtc: { iceServers: [{ urls: 'stun:stun.example.com:3478' }] },\n  chain: 'main'\n})\n\n// Yours: `seen`, a durable SeenStore (has/add), is what makes a re-delivered payment harmless;\n// `retryStore` is a durable store of payments to internalize later, with `size` and `keep`;\n// `isTransient` says whether a wallet failure will pass (wallet locked, storage, IPC or\n// network down).\nconst MAX_KEPT = 50\n\nawait client.startReceiving({\n  async onPayment(p) {\n    // 1. Check that output p.token.outputIndex pays this wallet's BRC-29 key for p.sender.\n    try {\n      await verifyDirectPayment(wallet, p, { originator })\n    } catch (e) {\n      // malformed, or it does not pay this wallet: nothing was stored\n      if (e instanceof WebRTPayError && e.code === 'invalid_payment') {\n        return { status: 'rejected', reason: e.message }\n      }\n      throw e // not checked yet (wallet failure): acked 'rejected', the payer keeps its token\n    }\n    // 2. It verified, so it is never refused. Internalize it once.\n    try {\n      await acceptDirectPayment(wallet, p, {\n        originator,\n        seen,\n        onSeenError: e => console.warn('payment taken but not recorded as seen', e)\n      })\n      return 'accepted'\n    } catch (e) {\n      // the wallet cannot take it right now: keep it (up to the cap) and retry locally\n      if (isTransient(e) && retryStore.size < MAX_KEPT) {\n        await retryStore.keep(p)\n        return 'received'\n      }\n      throw e // acked 'rejected' with a generic reason; the payer keeps its token\n    }\n  },\n  onError: e => console.warn('direct payment session failed', e)\n})\n```\n\n`p.sender` is the identity key the payer proved in the handshake. By the time a payment arrives\nthe payer has usually broadcast it, so a payment that verifies is never refused: when only\ninternalizing fails for a passing reason, keep it, retry locally and return `'received'`. Call\n`verifyDirectPayment` first and keep only failures after it: a failure before it says nothing\nabout the payment, and keeping those would let anyone fill the store. Keep them in a store with\na cap, and only for failures that pass: a wallet's argument or proof errors (a BEEF it cannot\nverify fails with `WERR_INVALID_PARAMETER`, say) do not. A handler that throws makes the ack\n`rejected`, so once the store holds `MAX_KEPT` payments the payer gets `rejected` (with the\n`txid`): it keeps its token and delivers it again later, directly or through MessageBox.\n`acceptDirectPayment` reports a payment the wallet took as `accepted` even when\n`seen.add` fails afterwards (`onSeenError` hears about it). Receiving answers offers\nautomatically within the inbound limits (3 sessions, 1 per sender, a sender's new dial\nreplacing its open session, 10 offers a minute, 3 per sender); see [Security and privacy](#security-and-privacy) before turning it on for users.\n\n### Pay (payer)\n\n`connect` knocks first: a sealed `knock` goes to the payee before any ICE is gathered, and a\nreceiving payee replies `ring` at once (or a sealed refusal). So an offline payee is known\nwithin `presenceTimeoutMs` (2 s) of the carrier taking the knock, and an online one has\n`dialTimeoutMs` (6 s) from its reply to open the channel and prove its identity; `timeoutMs` caps the whole dial (default `connectMs`,\n30 s). Set the defaults with the client's `timeouts: { presenceMs, dialMs, connectMs }` or per\ncall. `signal` (an `AbortSignal`) cancels one dial: it rejects `closed` (`Dial cancelled`) and\nleaves receiving and other dials alone. Map `peer_unreachable` and `rejected`\n(`not_receiving`) to \"isn't online\", and `unavailable` (the carrier refused this side) to\n\"check your connection\". Payees on 0.1.0 do not answer knocks and look offline.\n\nConnect first, then create the transaction: if the payee cannot be reached, nothing is spent. A\npayee that answered the dial waits 60 s (its `firstMessageTimeoutMs`) for the payment before it\nhangs up, and creating and broadcasting the transaction, permission prompts included, has to\nfit in that window. So have the user confirm before `connect`, sign the transaction without\nbroadcasting it (`noSend`), check that the session is still open (dial again if it is not),\nthen broadcast and send at once.\n\n```ts\nimport { P2PKH, PublicKey } from '@bsv/sdk'\nimport { PAYMENT_PROTOCOL_ID, randomBytes, toBase64, validatePaymentToken } from '@bsv/webrtpay'\nimport type { PaymentTokenInput } from '@bsv/webrtpay'\n\n// `wallet` takes the originator on every call (a WalletInterface such as a wallet-toolbox\n// Wallet); a WalletClient takes it in its constructor instead, and one argument per method.\n\n// 1. Dial and authenticate. Offline payees fail peer_unreachable within 2 s of the carrier\n//    taking the knock; it also rejects unavailable (MessageBox could not take the signal:\n//    this side's network), rejected\n//    (error.reason 'busy', 'not_receiving' or 'wrong_network'), rate_limited, timeout, closed\n//    (cancelled through `signal`) or auth_failed.\nconst dial = new AbortController() // abort() from the Cancel button\nlet peer = await client.connect(payeeKey, { signal: dial.signal })\n\n// 2. Build the payment as PeerPay does, P2PKH to the payee's BRC-29 key for a fresh\n//    derivation, and sign it without broadcasting it.\nconst derivationPrefix = toBase64(randomBytes(32))\nconst derivationSuffix = toBase64(randomBytes(32))\nconst { publicKey } = await wallet.getPublicKey(\n  {\n    protocolID: PAYMENT_PROTOCOL_ID,\n    keyID: `${derivationPrefix} ${derivationSuffix}`,\n    counterparty: payeeKey\n  },\n  originator\n)\nconst lockingScript = new P2PKH().lock(PublicKey.fromString(publicKey).toHash()).toHex()\nconst { tx, txid } = await wallet.createAction(\n  {\n    description: 'Direct payment',\n    outputs: [{ satoshis: 1000, lockingScript, outputDescription: 'Payment' }],\n    options: { randomizeOutputs: false, noSend: true }\n  },\n  originator\n)\nconst token: PaymentTokenInput = {\n  customInstructions: { derivationPrefix, derivationSuffix },\n  transaction: tx!, // number[] or Uint8Array\n  amount: 1000,\n  outputIndex: 0\n}\nvalidatePaymentToken(token) // what the payee checks short of its own key: invalid_payment\n\n// 3. Broadcast only while the payee is still there, then deliver and wait for its ack (45 s).\n//    If it hung up meanwhile, dial again first: nothing has been broadcast, so the same token\n//    still serves, and a dial that fails throws before anything goes out. The signed noSend\n//    transaction then stays in the wallet, unbroadcast, holding its inputs: abandon it with\n//    abortAction({ reference: txid }), or broadcast it later with sendWith and deliver the\n//    token another way (MessageBox).\nif (!peer.isOpen) peer = await client.connect(payeeKey)\nawait wallet.createAction(\n  { description: 'Direct payment', options: { sendWith: [txid!] } },\n  originator\n)\nconst ack = await client.sendPayment(peer, token)\n// ack.status: 'accepted' | 'received' | 'rejected' (ack.reason); the session then ends\n```\n\nKeep the token until the ack is `accepted` or `received`. If `sendPayment` fails after the\nbroadcast (the channel dropped, the ack timed out), or the ack is `rejected` and carries a\n`txid`, the payee may hold an output it cannot find without the derivation values: deliver the\nsame token again, directly or through MessageBox, rather than treating the payment as failed or\npaying again. The payee acknowledges a payment it already has without internalizing it twice.\n\n`sendPayment` sends nothing, and leaves the session as it was, for a token\n`validatePaymentToken` refuses (`invalid_payment`) or a transaction over\n`MAX_DIRECT_TRANSACTION_BYTES` (`too_large`): 12,579,840 bytes, about 12 MiB, what fits a\npayee's default 16 MiB message cap once base64-encoded. A session that has closed fails at\nonce with `closed`. A payee configured with a smaller cap answers an oversize payment with an\nerror, which `sendPayment` reports as `too_large`.\n\n**Networks.** An identity key is the same on every network, so a mainnet payer could otherwise\nreach a payee whose wallet is on testnet, broadcast, and have the payment refused. Give every\nclient the wallet's network (`chain: 'main'`, `'test'`, …: 1 to 16 characters from `a-z` and\n`0-9`). Dials then name it: a payee refuses an offer from another network before gathering\nanything, and a payer refuses an answer from another network before the channel opens. Either\nway `connect` rejects with `WebRTPayError` code `rejected` and\n`error.reason === 'wrong_network'` before anything is spent, so a UI can say \"the recipient's\nwallet is on a different network\". A client without `chain` names no network and checks none;\nclients with one refuse it, so set it on both sides. The constructor throws `TypeError` for any\nother `chain` (`'Main'`, `'main-net'`, 17 characters), also on a client that only exchanges\ncodes, although a code's own network field could hold any UTF-8 name: keep to short lower-case\nnames such as `main` and `test`, so that codes and signals name a network the same way.\n\n```ts\n// when connect rejects with e: show \"The recipient's wallet is on a different network\"\nconst onOtherNetwork = (e: unknown): boolean =>\n  e instanceof WebRTPayError && e.code === 'rejected' && e.reason === 'wrong_network'\n```\n\n### Face to face (code exchange, no server)\n\nThe payee shows an offer code; the payer scans it and shows an answer code; the payee scans\nthat. The payee is the offerer because an offer can wait for minutes while the answer must be\napplied within about 15 s (the answerer's ICE timer), and the payee is the one scanning then.\nThat window starts when the payer calls `answerOfferCode`, which gathers, starts the ICE\nchecks toward the payee and starts the 30 s timer of `connected`: the payer reviews the offer\nwith `decodeCode` and confirms before answering, then shows the answer code at once. Pasting\ncodes through a chat app usually misses the window.\n\n```ts\nimport { decodeCode } from '@bsv/webrtpay'\n// showQr, scanQr and confirmWithUser stand for your UI.\n\n// Payee: no signaler needed. `handlers` are ReceiveHandlers, as for startReceiving.\nconst offer = await client.createOfferCode({ amount: 1000, expiresInMs: 5 * 60_000, handlers })\nshowQr(offer.code)\nconst payerSession = await offer.complete(await scanQr()) // payerSession.peerIdentityKey\n\n// Payer: review first, as answering starts the clock.\nconst text = await scanQr()\n// refuses an expired code and one for another network\nconst preview = decodeCode(text, { expectedChain: 'main' })\nif (preview.kind !== 'offer') throw new Error(\"Scan the payee's offer code\")\nif (await confirmWithUser(preview.identityKey, preview.amount)) {\n  const answer = await client.answerOfferCode(text) // ICE and the connect timer start here\n  showQr(answer.answerCode)\n  const peer = await answer.connected // the payee proved preview.identityKey\n  // prepare as in Pay; if peer.isOpen is false, broadcast nothing and start over with new\n  // codes (a code exchange cannot be redialed); on giving up, answer.cancel()\n  const ack = await client.sendPayment(peer, token)\n}\n```\n\nAn offer code carries the payee's identity key, network, optional amount and expiry and its\nSDP in a compact binary form (roughly 250 characters for a typical browser offer, a QR code of\nversion 10 or so). An answer code carries only the session id and the SDP: the payer's\nidentity comes from the handshake.\n\nA runnable version of the remote flow is [examples/demo-node.ts](examples/demo-node.ts): two\nwallets in one process, a real WebRTC channel, an in-memory signaler. It uses node-datachannel,\na development dependency, so run it in [`packages/messaging/webrtpay`](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/webrtpay) of a\n[ts-stack](https://github.com/bsv-blockchain/ts-stack) checkout, after `pnpm install` at the\nrepository root:\n\n```sh\npnpm build && node --experimental-strip-types examples/demo-node.ts\n```\n\n## Signalers\n\nA `Signaler` carries already-encrypted envelopes between identity keys; it never sees an SDP.\n\n| Signaler                                             | Server                 | Use                                            |\n| ---------------------------------------------------- | ---------------------- | ---------------------------------------------- |\n| code exchange (`createOfferCode`, `answerOfferCode`) | none                   | in person, by QR                               |\n| `MessageBoxSignaler(client, options?)`               | an existing MessageBox | remote, no new infrastructure                  |\n| `RelaySignaler(options)`                             | `@bsv/webrtpay/server` | remote, low latency, can vend TURN credentials |\n| `createMemoryHub().signaler(key)`                    | none (one process)     | tests and demos                                |\n\n**`MessageBoxSignaler`** is typed structurally against the `MessageBoxClient` methods it uses\n(`sendMessage`, `listMessages`, `acknowledgeMessage`, and optionally `sendLiveMessage`,\n`listenForLiveMessages`, `joinRoom`, `leaveRoom`, `resolveHostForRecipient` and\n`getIdentityKey`), so there is no dependency on `@bsv/message-box-client`; a `PeerPayClient`\nworks too. A live websocket reaches only the host it is connected to, so the signaler sends\nlive only to a recipient whose host (`resolveHostForRecipient`, remembered for 5 minutes) is\nthis client's: `host` when given, else this client's own advertised host. Everyone else, and\nevery live send that fails, goes over HTTP, which resolves the recipient's own host, so payer\nand payee may use different servers. A live send gets 1.5 s (host lookup included) before the\nHTTP send, and an HTTP `ERR_DUPLICATE_MESSAGE` after it means the live one got there. It\nreceives signals live as they arrive; it also polls box `webrtpay_signals` every 2.5 s (1 s while dialing), as the\nsafety net and the only path for clients without the websocket, and acknowledges every message\nit reads, once per `messageId` whichever path brought it. When neither send path gets a signal\nthere, `send` rejects `unavailable` (it was `peer_unreachable` in 0.1.0). Options:\n`messageBox`, `idlePollMs`, `activePollMs`, `live` (default true; false polls only), and\n`host`, the MessageBox host to list, acknowledge and listen live on, and the host compared\nwith a recipient's for live sends.\n\n**`RelaySignaler`** keeps one authenticated WebSocket to a relay, and only while it is needed:\nwhile subscribed, while a send is in flight, or after `connect()`. Options: `url` (`ws:` or\n`wss:`), `wallet`, `originator`, `WebSocket` (defaults to the global one), `onIceServers`,\n`onStateChange`, `onCallbackError` and `random`. A throw from `onIceServers` or\n`onStateChange` never reaches the connection; it goes to `onCallbackError`.\n\n- `listen()` resolves once the connection is authenticated or the first attempt has failed. The\n  subscription stays either way, and while it lasts a lost or refused connection is retried in\n  the background after a random delay whose cap doubles with each failure, up to 30 s.\n- `onStateChange(state, error?)` reports `connecting`, `online` and `offline`\n  (`RelayConnectionState`); use it for a \"receiving\" indicator. When it goes `offline` because\n  the connection or an attempt to open it failed, `error` is the `WebRTPayError` that ended it,\n  so the app can say why: `auth_failed` when the relay expects another url (its `PUBLIC_URLS`\n  does not list the one dialed), `rate_limited`, or `peer_unreachable` when the relay cannot be\n  reached or dropped the connection. Going `offline` on purpose (nothing needs the connection,\n  or `close()`) passes no error.\n- The relay answers each frame in order; a reply that does not match ends the connection with\n  `protocol` rather than being matched to a later frame.\n- `requestTurn()` asks for TURN credentials, spending one of this address's credentials, and\n  resolves with the full ICE server list. It rejects `rate_limited` when the relay's budget is\n  spent and `unavailable` when the relay's TURN provider (Cloudflare) could not issue\n  credentials just then. `iceServers()` returns the latest list without TURN entries that\n  expire within 30 s, going by the `expiresAt` the relay sends with them (or, from an older\n  relay, the expiry in a coturn REST username); every new connection resets it to the relay's\n  STUN servers. `turnProvider()` returns the `iceServersProvider` to give a `DirectPayClient`\n  (below).\n\nAnything with `send`, `listen` and optionally `setActive` can be a signaler; see `Signaler` in\n[`src/signaling/types.ts`](https://github.com/bsv-blockchain/ts-stack/blob/main/packages/messaging/webrtpay/src/signaling/types.ts).\n\n## ICE servers and TURN\n\n`rtc.iceServers` (STUN and TURN servers) applies to every link; `updateRtc()` changes it for\nlinks created later. For short-lived TURN credentials pass `iceServersProvider`, which is asked\nbefore every ICE gather (dialing, answering an offer, creating or answering an offer code).\nWith a relay that issues TURN, use `RelaySignaler.turnProvider()`:\n\n```ts\nconst relay = new RelaySignaler({ url: 'wss://relay.example.com', wallet, originator })\nconst client = new DirectPayClient({\n  wallet,\n  originator,\n  signaler: relay,\n  chain: 'main',\n  rtc: { iceServers: [{ urls: 'stun:stun.example.com:3478' }] },\n  iceServersProvider: relay.turnProvider(),\n  onIceServersProviderError: e => console.warn('gathering without the relay TURN servers', e)\n})\n```\n\n`turnProvider()` reuses the servers of the last `requestTurn()` while one of their TURN entries\nhas more than 30 s left, and asks the relay only otherwise; calls made while a request is in\nflight share it, a failed request is not remembered, and what it keeps survives reconnects\n(unlike `iceServers()`). Do not pass `() => relay.requestTurn()`: every request spends one of\nthis address's TURN credentials (6 an hour by default), a payee gathers for every inbound offer\nit admits, and identities are free, so strangers could use up the budget by sending offers. A\nrelay without TURN answers with STUN only, so it is asked again on each gather (a frame, no\nbudget).\n\nThe provider's servers are merged with `rtc.iceServers`; an entry with the same set of urls is\nreplaced by the provider's. A provider that throws, rejects, returns something other than an\narray or takes more than 5 s is passed over and the gather uses `rtc.iceServers` alone, so a\ndial never fails because of it. `onIceServersProviderError` is told why each time: what the\nprovider threw or rejected with (`rate_limited` once the relay's budget is spent,\n`unavailable` when the relay's TURN provider failed), a `TypeError` for a result that is not\nan array, or a `timeout` `WebRTPayError` after 5 s.\n\n**Node with node-datachannel.** Its polyfill hands each TURN server to libdatachannel as a\n`turn:<username>:<credential>@host` url, which cuts a REST username (`<expiry>:<pseudonym>`)\nat its colon, so coturn refuses relay-issued credentials and relayed connections fail without\nan error. (Cloudflare's usernames have no colon, but libdatachannel's ICE library uses at most\ntwo TURN servers, over UDP only.) Give libdatachannel TURN servers as native `IceServer`\nobjects instead, through the polyfill's `peerConnection` option (this is what the repository's\nTURN tests run):\n\n```ts\nimport { PeerConnection } from 'node-datachannel'\nimport type { IceServer, RtcConfig } from 'node-datachannel'\nimport { RTCPeerConnection as Polyfill } from 'node-datachannel/polyfill'\n\nlet made = 0\nclass TurnRTCPeerConnection extends Polyfill {\n  constructor(config: RTCConfiguration & { bindAddress?: string } = {}) {\n    const native: RtcConfig = { iceServers: (config.iceServers ?? []).flatMap(nativeIceServers) }\n    if (config.bindAddress !== undefined) native.bindAddress = config.bindAddress\n    if (config.iceTransportPolicy !== undefined) {\n      native.iceTransportPolicy = config.iceTransportPolicy\n    }\n    super({ ...config, peerConnection: new PeerConnection(`webrtpay-${++made}`, native) })\n  }\n}\n\n/** `server`'s urls for libdatachannel: TURN as native IceServer objects, others as they are. */\nfunction nativeIceServers(server: RTCIceServer): Array<string | IceServer> {\n  const urls = typeof server.urls === 'string' ? [server.urls] : server.urls\n  return urls.map(url => {\n    const turn = /^(turns?):([^:?]+)(?::(\\d+))?(?:\\?transport=(udp|tcp))?$/i.exec(url)\n    if (turn === null) return url\n    const [, scheme, hostname, port, transport] = turn\n    const tls = scheme.toLowerCase() === 'turns'\n    return {\n      hostname,\n      port: port === undefined ? (tls ? 5349 : 3478) : Number(port),\n      username: server.username,\n      password: server.credential,\n      relayType: tls ? 'TurnTls' : transport?.toLowerCase() === 'tcp' ? 'TurnTcp' : 'TurnUdp'\n    }\n  })\n}\n\nconst client = new DirectPayClient({\n  wallet,\n  signaler: relay,\n  rtc: { RTCPeerConnection: TurnRTCPeerConnection as unknown as typeof RTCPeerConnection },\n  iceServersProvider: relay.turnProvider()\n})\n```\n\n## Security model\n\n- **Identity bound to the channel.** After the data channel opens, each side sends `hello`\n  (identity key, fresh 32-byte nonce), then `auth`: a signature under protocol\n  `[2, 'webrtpay channel']` over a transcript of both sides' DTLS fingerprints, both nonces and\n  both keys. A man in the middle has to terminate DTLS separately with each side, so each sees\n  a different remote fingerprint and neither signature verifies; the session closes before any\n  payment byte is sent. Each side then raises its message cap from 64 KiB to the full size and\n  sends `ready`; nothing else may be sent before both `ready`s. Fingerprint verification is\n  never disabled.\n- **Pinned peers.** The payer pins the identity it means to pay (the dialed key, or the key in\n  the offer code); any other key closes the session. The payee learns the payer from the\n  handshake, as MessageBox gives it the authenticated sender.\n- **Funds safety does not depend on any of that.** The payee accepts a payment only if output\n  `outputIndex` is P2PKH to its own key derived with `[2, '3241645161d8']`, keyID\n  `\"<prefix> <suffix>\"`, counterparty the payer, and carries exactly the stated amount.\n- **Encrypted signaling.** Offers, answers and rejects are BRC-2 encrypted under\n  `[2, 'webrtpay signaling']` to the recipient, must name the carrier's sender and recipient,\n  must be within ±120 s, and each session id is accepted once. A carrier sees only\n  `{ v, sid, ciphertext }`.\n- **Hostile input.** Frames, codes, envelopes and messages are parsed with fixed bounds (frame\n  and message caps, JSON depth 32 and 4096 structural tokens before `JSON.parse`, strict\n  base64/hex/UTF-8, unknown fields dropped). A violation closes the session with an error code;\n  nothing throws out of the client.\n- **Connect first, then pay; deliver idempotently.** The payer spends only after the payee has\n  authenticated. `acceptDirectPayment` records each payment it internalizes\n  (`txid:outputIndex:prefix:suffix`) in the `seen` store it is given and reports a re-delivery\n  as `duplicate` without a second `internalizeAction`; the client decides concurrent deliveries\n  of one payment once.\n\n## Security and privacy\n\nRead this before enabling receiving for users.\n\n- **What carriers learn.** The MessageBox host or relay, and Cloudflare or any proxy that\n  terminates TLS in front of them, see both identity keys, both IP addresses, the timing and\n  the size of every knock, ring, offer, answer and reject. MessageBox stores each encrypted signal until it\n  is acknowledged (the signaler acknowledges as soon as it reads); a receiving wallet polls it\n  every 2.5 s, which shows when it is online. Carriers never see the payment.\n- **Receiving reveals your IP address and online status.** While receiving is on, any wallet\n  that knows your identity key can send an offer and get an answer containing your public IP\n  address, and learn that your wallet is online, without paying you. The inbound limits bound\n  the load, not this disclosure (offers refused by the limits gather no addresses). Make\n  receiving opt-in; BSV Desktop has it off by default.\n- **Dialing reveals the payer's IP address to the payee**, and both codes in a face-to-face\n  exchange show the sender's candidate addresses to anyone who can read the QR code. Browsers\n  hide LAN addresses behind mDNS names; Electron apps must allow that (BSV Desktop installs a\n  permission check handler); Node does not hide them. The STUN and TURN servers you configure\n  see the address of each side that gathers.\n- **The relay is a presence oracle.** Its `offline` reply tells any authenticated key whether\n  another identity is connected right now. It is a known trade-off, kept so that a dial to a\n  payee who is not listening fails at once instead of after 30 s; `RelaySignaler` holds a\n  socket only while receiving or dialing, so the oracle shows exactly that. A client that is\n  dialing but not receiving answers offers with `not_receiving`, which also shows it is online.\n- **A ring is a presence reply.** Any wallet that knows a receiving payee's identity key can\n  knock and learn from the `ring` that it is online, without its IP address (no ICE is\n  gathered for a knock). Rings are capped at 20 a second, and knocks count against the intake\n  budgets like offers.\n- **The protocol IDs are ordinary BRC-43 protocols.** `[2, 'webrtpay channel']`,\n  `[2, 'webrtpay signaling']` and `[2, 'webrtpay relay']` can be used by any originator the\n  wallet allows. The channel binding therefore proves \"an originator with signing permission\n  for `[2, 'webrtpay channel']` (for this counterparty) on this key's wallet controls the\n  endpoint\". Wallets should prompt other originators before signing under\n  `[2, 'webrtpay channel']`, and a wallet that answers automatically should use its own admin\n  originator.\n- **TURN.** The TURN service sees the address of each side that relays through it, the ports\n  and the timing, never the payment (DTLS-encrypted end to end). Relay-issued coturn usernames\n  are pseudonyms (`<expiry>:<HMAC of the identity key>`), but they travel in the clear on\n  `turn:` urls: do not run coturn verbose or with its Redis stats database, which log them.\n  With Cloudflare the usernames are random and the relay never sends Cloudflare the identity\n  key. See [TURN options](#turn-options).\n- **Known limitation: free identities can keep direct receive busy.** A few throwaway keys can\n  each dial, complete the handshake and send any message (one the payee ignores) within the\n  first-message window, then one a minute, holding every inbound slot. `maxSessionMs` (120 s)\n  ends each session, but redialing within the per-sender offer limits takes the slot again, so\n  genuine payers get `busy` or no answer for as long as it goes on. Nothing is spent, and\n  MessageBox remains the fallback; reserving a slot for known counterparties is possible future\n  work.\n\n## Relay server\n\n`@bsv/webrtpay/server` is a stateless WebSocket rendezvous: it authenticates sockets by\nidentity key, forwards envelopes between them, and can issue ephemeral TURN credentials, from\nCloudflare Realtime TURN or for a coturn server (see [TURN options](#turn-options)). It stores\nnothing and never logs payloads or secrets.\n\n```sh\nPUBLIC_URLS=wss://relay.example.com TRUSTED_IP_HEADER=cf-connecting-ip \\\nTURN_PROVIDER=cloudflare CLOUDFLARE_TURN_KEY_ID=… CLOUDFLARE_TURN_API_TOKEN=… \\\nnpx --package @bsv/webrtpay webrtpay-relay\n```\n\nSet `TRUSTED_IP_HEADER` only when the listener can be reached solely through the proxy that sets\nit: keep it private (a ClusterIP Service behind the tunnel, a loopback or cluster-internal\naddress, a firewall or the proxy's own allow-list). A client that can reach the relay directly\ncan send any value and choose its own per-source limits.\n\n| Variable                           | Default                            | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |\n| ---------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `PORT`                             | `8787`                             | Port to listen on.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `HOST`                             | `0.0.0.0`                          | Address to listen on.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `PUBLIC_URLS`                      | —                                  | Comma-separated `wss://` urls clients dial; clients sign the one they use. **Required in production**: the relay refuses to start without it unless `HOST` is a loopback address, because otherwise clients sign a url built from the `Host` header, which the client chooses. Set it whenever a proxy fronts the relay, even on loopback.                                                                                                                                                                                                          |\n| `TRUSTED_IP_HEADER`                | —                                  | Header the outermost proxy sets to the client's single address, for the per-source limits: `cf-connecting-ip` behind Cloudflare. Without it every client behind a proxy shares the proxy's limits. `x-forwarded-for` is right only behind exactly one proxy that overwrites it. Set it only when every connection reaches the relay through that proxy (firewall it, or keep the listener private, e.g. a ClusterIP Service behind the tunnel); a client that can reach the relay directly can send any value and choose its own per-source limits. |\n| `STUN_URLS`                        | —                                  | Comma-separated STUN urls handed to every authenticated socket.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `TURN_PROVIDER`                    | — (coturn when TURN_SECRET is set) | Where TURN credentials come from: `coturn` (REST credentials the relay mints; what runs when `TURN_SECRET` is set and this is not) or `cloudflare` (Cloudflare Realtime TURN). Without it and without `TURN_SECRET` the relay issues no TURN. Enable `cloudflare` only once every client is on a package version that reads `ice.expiresAt` (see [TURN options](#turn-options)).                                                                                                                                                                    |\n| `TURN_SECRET`                      | —                                  | coturn `static-auth-secret`; requires `TURN_URLS`. Refused with `TURN_PROVIDER=cloudflare`.                                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `TURN_URLS`                        | —                                  | Comma-separated TURN urls to issue credentials for; requires `TURN_SECRET`. Refused with `TURN_PROVIDER=cloudflare`, which chooses its own.                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `CLOUDFLARE_TURN_KEY_ID`           | —                                  | The Cloudflare TURN key's id; requires `TURN_PROVIDER=cloudflare` and `CLOUDFLARE_TURN_API_TOKEN`.                                                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `CLOUDFLARE_TURN_API_TOKEN`        | —                                  | The TURN key's API token (a secret: never logged); requires `TURN_PROVIDER=cloudflare` and `CLOUDFLARE_TURN_KEY_ID`.                                                                                                                                                                                                                                                                                                                                                                                                                                |\n| `TURN_TTL`                         | `600`                              | Lifetime of issued TURN credentials, in seconds; Cloudflare caps it at 172800 (48 hours).                                                                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `TURN_PER_IP_PER_HOUR`             | `6`                                | TURN credential sets one source address may get per hour; requires a TURN provider.                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `TURN_PER_48_PER_HOUR`             | `24`                               | The same for all the IPv6 sources of one /48 together; requires a TURN provider.                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `TURN_PER_HOUR`                    | `600`                              | TURN credential sets the relay issues per hour in total; requires a TURN provider.                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `SOCKETS_PER_IP`                   | `20`                               | Sockets one source address may hold.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |\n| `CONNECTIONS_PER_IP_PER_MINUTE`    | `30`                               | New connections one source address may open a minute.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `SOCKETS_PER_48`                   | `80`                               | Sockets all the IPv6 sources of one /48 may hold together.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `CONNECTIONS_PER_48_PER_MINUTE`    | `120`                              | New connections all the IPv6 sources of one /48 may open a minute.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `MAX_SOCKETS`                      | `10000`                            | Sockets the relay holds.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `MAX_UNAUTHENTICATED_SOCKETS`      | `1000`                             | Sockets not yet authenticated the relay holds.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `SIGNALS_PER_PAIR_PER_MINUTE`      | `6`                                | Signals all the identities behind one source address (an IPv4 address or an IPv6 /64) may send one recipient a minute, together.                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `SIGNALS_PER_RECIPIENT_PER_MINUTE` | `120`                              | Signals one recipient may receive a minute from all senders.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |\n| `MAX_OVER_RATE_FRAMES`             | `20`                               | Frames over the socket rate answered in a row before the socket is closed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n\n- `GET /health` answers `200 {\"status\":\"ok\"}` (liveness). `GET /ready` answers\n  `200 {\"status\":\"ready\"}` while the relay accepts connections and `503 {\"status\":\"closing\"}` once\n  it is closing (readiness). Both take `HEAD` and send `Access-Control-Allow-Origin: *` (never\n  with credentials); `OPTIONS` on either answers a CORS preflight with `204`,\n  `Access-Control-Allow-Methods: GET, HEAD, OPTIONS` and `Access-Control-Allow-Headers` listing\n  `Content-Type` plus every well-formed header asked for. Other paths answer `404`. `SIGTERM` or\n  `SIGINT` closes every socket and exits 0; an invalid setting exits 1.\n- At startup it logs `TURN credentials from <provider>, valid <ttl> s`, naming the provider by\n  its `kind` in code: `coturn-rest` for `TURN_PROVIDER=coturn`, or `cloudflare`. It warns when\n  there are no public urls (development only) and when `PUBLIC_URLS` is set without\n  `TRUSTED_IP_HEADER`.\n  It refuses to start with a half-configured or mixed TURN provider (`TURN_URLS` without\n  `TURN_SECRET`, `TURN_PROVIDER=cloudflare` without both Cloudflare variables or with\n  `TURN_SECRET`, Cloudflare variables without `TURN_PROVIDER=cloudflare`) rather than run\n  without TURN.\n- Connections are admitted per source address before the WebSocket upgrade: more than 20\n  sockets or 30 new connections a minute from one source get `HTTP 429`; more than 10000\n  sockets, or 1000 not yet authenticated, get `HTTP 503`. IPv6 counts by its /64 and also by its\n  /48, at 4 times those limits (80 sockets, 120 connections a minute), since a free\n  tunnel-broker /48 holds 65,536 /64s; an organisation that shares one /48 shares its budget\n  too. Every user behind one carrier-grade NAT address shares that address's limits; raise them\n  with the variables above when many of your users do.\n- A socket may send 20 frames per 10 s, WebSocket pings included. Each frame over that is\n  answered `rate_limited` (pings get no pong) until 20 have come in a row; the next closes the\n  socket with 1008, so a client that floods, reading the replies or not, is cut off.\n- An identity may hold 5 sockets and receive 120 signals a minute from all senders together,\n  6 a minute from any one source address (its /64 for IPv6), however many identities connect\n  from it. A sender over its address's share gets `rate_limited`; when the total is spent the\n  sender gets `offline`, as if the recipient were not connected. Identities are free and\n  addresses are not, so the share is per address: fresh identities from one address share it.\n  Every user behind one carrier-grade NAT address therefore shares 6 signals a minute toward\n  one recipient; raise `SIGNALS_PER_PAIR_PER_MINUTE` when many of your users do.\n- An envelope may be up to 24672 bytes, the largest a signal can produce (a 16384-character\n  SDP fits).\n- TURN credentials are issued only on request (`{ t: 'turn' }`), at most 6 an hour per source\n  address (`TURN_PER_IP_PER_HOUR`) and 600 an hour in total, valid for `TURN_TTL`, whichever\n  the provider. The budget is spent before the provider is asked, so a request Cloudflare fails\n  costs one too. The answer lists the relay's `STUN_URLS`, then the provider's servers, with\n  `expiresAt`. A provider that fails (Cloudflare answers an error, nothing within 5 s, or more\n  than 64 KiB) gets the client `{ t: 'error', code: 'unavailable' }` and one log line naming\n  what failed, never the token or credentials. Other frames from that socket are still\n  forwarded at once, and their replies follow the TURN reply in order.\n- coturn REST: the username is `<expiry>:<pseudonym>`\n  (`credential = base64(HMAC-SHA1(secret, username))`), so coturn needs `use-auth-secret`.\n  Identities are free, so in production also give coturn its own quotas (`--total-quota`,\n  `--max-bps`, `--denied-peer-ip` for private ranges); `--user-quota` alone does not stop\n  fresh keys.\n\nIn code, `createRelayServer(options)` takes the same settings (`port`, `host`, `publicUrls`,\n`trustedIpHeader`, `stunUrls`, `turn` as `{ kind: 'coturn-rest', secret, urls, ttlSeconds }`\nor `{ kind: 'cloudflare', keyId, apiToken, ttlSeconds, fetch }`, and `limits` for the limit\nvariables, e.g. `limits: { turnPerIpPerHour }` for `TURN_PER_IP_PER_HOUR`) plus `server`\n(attach to an existing HTTP server), `path`, the other `limits` (see `DEFAULT_RELAY_LIMITS`),\n`log` and, for development only, `trustHostHeader`. It resolves to `{ url, port, close(), stats() }`.\n`webrtpay-relay --help` lists the variables with their defaults. Attached to your own server\n(`server`), the relay handles only WebSocket upgrades on `path`: `/health`, `/ready` and CORS are\nthen your server's.\n\nTo start the relay exactly as the CLI does, for example from a container entrypoint that adds\nits own logging, use the CLI's environment parser and help text from the same entry:\n\n```ts\nimport { createRelayServer, relayOptionsFromEnv, relayUsage } from '@bsv/webrtpay/server'\n\nif (process.argv.includes('--help')) {\n  console.log(relayUsage())\n} else {\n  const relay = await createRelayServer({ ...relayOptionsFromEnv(process.env), log: console.log })\n  process.once('SIGTERM', () => {\n    relay.close().then(() => process.exit(0))\n  })\n}\n```\n\n`relayOptionsFromEnv(env)` throws an `Error` naming the variable on an invalid or missing value;\n`DEFAULT_HOST` (`0.0.0.0`) and `DEFAULT_RELAY_PORT` (`8787`) are exported too.\n\n## TURN options\n\nMost connections need no TURN; it carries the (encrypted) payment only when neither side can\nreach the other directly, as on many mobile networks. The relay issues short-lived credentials\nfor one of two providers.\n\n**Cloudflare Realtime TURN (`TURN_PROVIDER=cloudflare`; recommended for production, and must be\nset explicitly).**\n\n- Rollout: enable `TURN_PROVIDER=cloudflare` only after every client is on a package version\n  that reads `ice.expiresAt` (this version on). Older clients work out when TURN credentials\n  expire from the `<expiry>:<pseudonym>` username of coturn REST credentials, and treat\n  Cloudflare's opaque usernames as never expiring.\n- Setup: create a TURN key in the Cloudflare dashboard, or with\n  `POST https://api.cloudflare.com/client/v4/accounts/{account_id}/calls/turn_keys` and an API\n  token with Calls Write permission. The key has an id (`uid`) and an API token (`key`); give\n  them to the relay as `CLOUDFLARE_TURN_KEY_ID` and `CLOUDFLARE_TURN_API_TOKEN`, from a secret\n  store. Use one key per environment.\n- For each `turn` request the relay calls\n  `POST https://rtc.live.cloudflare.com/v1/turn/keys/<key id>/credentials/generate-ice-servers`\n  with `{ \"ttl\": TURN_TTL }`. It passes on Cloudflare's STUN url and its TURN urls (UDP 3478\n  and 443, TCP 3478 and 80, TLS 5349 and 443, anycast to the nearest Cloudflare location),\n  picked by scheme rather than by position because the answer's shape has changed before, and\n  drops any url on port 53, which browsers block. It reads at most 64 KiB of the answer and\n  forwards at most 8 TURN urls (the first listed; Cloudflare lists 6). Cloudflare's answer\n  does not say when the credentials expire, so the relay sends `expiresAt` (request time +\n  ttl). The log line for a failed request says what failed in a few words (`HTTP 503`,\n  `no answer within 5 s`, `request failed (ECONNREFUSED)`), never the url or the token.\n- Cost: TURN is billed at $0.05 per GB sent from Cloudflare to TURN clients, after the first\n  1,000 GB a month, which are free and shared with Cloudflare's Realtime SFU; STUN is free. A\n  direct payment is about 100 KB, so even with every session relayed, 1,000 sessions a day\n  stay far inside the free tier.\n- Privacy: Cloudflare sees each relayed client's IP address, ports and timing, never the\n  payment (DTLS end to end), and never the identity key (the usernames are random). There is\n  no data-localization guarantee: Realtime TURN is not covered by Cloudflare's Data\n  Localization Suite, and an allocation is made at the client's nearest location.\n- Risks: Cloudflare's budget alerts are e-mail only and informational; nothing caps the spend\n  except the relay's own issuance limits (6 an hour per address, 600 an hour in total by\n  default) and the short ttl, so set a budget alert anyway. It is a third-party dependency\n  (maintenance can drop allocations; clients recover by connecting again), relays IPv4 only,\n  and refuses private and loopback peers. Credentials can be revoked through Cloudflare's API\n  (the relay does not do this itself).\n\n**Self-hosted coturn (`TURN_PROVIDER=coturn`, with `TURN_SECRET` and `TURN_URLS`).** The relay\nmints coturn REST credentials with the shared secret; nothing is asked of a third party, and\nonly you see the addresses. It is what the [local stack](#running-the-local-stunturn-stack)\nruns. In production coturn needs:\n\n- a public address it can send from as well as receive on: `external-ip` set to it, every relay\n  port reachable on that address with the same port number, and its own first packets leaving\n  from it. A load balancer only answers flows that clients start, so coturn behind one, with\n  egress through a NAT gateway, fails whenever coturn has to send first; give it a host or\n  node with its own public IP and host networking instead;\n- a relay port range wide enough for the expected sessions (an allocation lives 10 minutes\n  after a client vanishes), UDP 3478 plus TCP 3478 and TLS 5349 or 443 for networks that block\n  UDP;\n- `denied-peer-ip` for private, loopback, link-local, IPv4-mapped IPv6 and NAT64 ranges, with\n  `allowed-peer-ip` for its own address so that two relayed peers on the same coturn can reach\n  each other, plus `--total-quota`, `--max-bps` and `--bps-capacity`;\n- a current release (4.18.0 removed `--no-dtls`) and secret rotation as the only way to cut\n  off issued credentials, which cannot be revoked.\n\n## Running the local STUN/TURN stack\n\n`docker/` runs coturn (STUN and TURN with REST credentials) for development and the TURN tests;\nthe relay runs on the host from the build, in a second terminal:\n\n```sh\ndocker compose -f docker/docker-compose.yml up -d\npnpm build && HOST=127.0.0.1 STUN_URLS=stun:127.0.0.1:3478 TURN_SECRET=webrtpay-dev-secret \\\n  TURN_URLS=turn:127.0.0.1:3478 TURN_PER_IP_PER_HOUR=600 node dist/server/cli.js\npnpm test:turn    # in the first terminal\ndocker compose -f docker/docker-compose.yml down\n```\n\n- coturn 4.18.0 listens on `127.0.0.1:3478` (UDP and TCP) with relay ports 49160–49200; the\n  relay on `ws://127.0.0.1:8787` hands out `stun:127.0.0.1:3478` and credentials for\n  `turn:127.0.0.1:3478`. Everything listens on 127.0.0.1 only. The secret\n  `webrtpay-dev-secret` is public: never deploy this stack.\n- coturn has a fixed address, 172.30.78.10 on subnet 172.30.78.0/24; if that clashes with a\n  network on your machine, change it in `docker-compose.yml` (two places) and `turnserver.conf`.\n- `external-ip=127.0.0.1/172.30.78.10` has a private half on purpose. coturn maps a peer address\n  equal to its advertised address back to its relay address; with a bare `external-ip` and a\n  `0.0.0.0` listener that relay address is `0.0.0.0` and coturn refuses every peer with\n  `403 Forbidden IP`. `allowed-peer-ip=172.30.78.10` is a development-only exception to the\n  private-range deny list.\n- `pnpm test:turn` runs the normal suite plus `test/turn.test.ts`: two relay-only peers,\n  a full payment over TURN with `RelaySignaler.turnProvider()` as `iceServersProvider`, and the\n  dev relay's credentials against the compose coturn. That last test spends one credential of\n  the dev relay, where every client arrives from 127.0.0.1; the command above therefore sets\n  `TURN_PER_IP_PER_HOUR=600` (the relay's total) instead of the default 6, so the suite can be\n  run again and again (restarting the relay starts over).\n- coturn's 41 relay ports are the other limit: a run holds 5 allocations, which outlive it\n  until their lifetime runs out, so about 8 runs in a row fill them. The TURN tests then time\n  out and coturn logs `create_relay_ioa_sockets: no available ports`; run\n  `docker compose -f docker/docker-compose.yml restart coturn` to free them.\n- The TURN tests give node-datachannel its TURN servers as native `IceServer` objects (see\n  [ICE servers and TURN](#ice-servers-and-turn)), since its polyfill cuts a REST username at\n  its colon.\n\n## React Native\n\nInject the platform's peer connection:\n\n```ts\nimport { RTCPeerConnection } from 'react-native-webrtc'\n\nconst client = new DirectPayClient({\n  wallet,\n  signaler,\n  rtc: { RTCPeerConnection: RTCPeerConnection as unknown as typeof globalThis.RTCPeerConnection }\n})\n```\n\nThe root entry also needs `TextEncoder`, `TextDecoder` with `{ fatal: true }`,\n`crypto.getRandomValues` (for example from `react-native-get-random-values`) and\n`queueMicrotask`, and uses `performance.now()` when there is one; `RelaySignaler` uses the\nglobal `WebSocket` unless given one. For the type declarations, add `\"DOM\"` to `lib` (see\n[Install](#install)). It uses no\nNode builtins, `Buffer` or `process`, and imports nothing outside `src/`\n(`test/browser-safety.test.ts` enforces this). It has not been run on React Native.\n\n## Development\n\nThe package lives in [`packages/messaging/webrtpay`](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/webrtpay) of\n[ts-stack](https://github.com/bsv-blockchain/ts-stack), whose\n[CONTRIBUTING.md](https://github.com/bsv-blockchain/ts-stack/blob/main/CONTRIBUTING.md) applies.\nAfter `pnpm install` at the repository root, from the package directory:\n\n| Command              | What                                                                                 |\n| -------------------- | ------------------------------------------------------------------------------------ |\n| `pnpm build`         | Compile `src/` and `server/` to `dist/` (`dist/src/index.js`, `dist/server/cli.js`). |\n| `pnpm test`          | All tests (jest, ESM); the TURN suite is skipped.                                    |\n| `pnpm test:coverage` | The same with coverage and its thresholds.                                           |\n| `pnpm test:property` | The fast-check property suites only.                                                 |\n| `pnpm test:turn`     | Also the TURN suite (needs the local STUN/TURN stack).                               |\n| `pnpm typecheck`     | `tsc --noEmit` over `src/`, `server/`, `test/` and `examples/`.                      |\n| `pnpm lint`          | Oxlint over `src/`, `server/`, `test/` and `examples/`, warnings denied.             |\n| `pnpm format:check`  | Prettier with the repository's configuration.                                        |\n| `pnpm test:browser`  | Bundles the packed root entry for browsers, within `browser-budget.json`.            |\n| `pnpm pack:check`    | Checks the packed tarball, its exports and the `webrtpay-relay` executable.          |\n\nNode tests run real WebRTC with node-datachannel bound to `127.0.0.1`.\n\n## Verification\n\nWhat the test suite exercises (`pnpm test`: 768 tests at 0.1.0, 914 at 0.2.0, 925 at 0.2.1; `pnpm test:turn` adds 3 TURN\ntests):\n\n- codes, compact SDP, frames: unit tests plus fast-check property suites with hostile input\n  (truncation, oversized and out-of-order frames, JSON depth and width bombs);\n- the identity handshake with real keys (`ProtoWallet`) over real node-datachannel loopback\n  connections: swapped fingerprints, a relay forwarding the handshake between two links, wrong\n  keys, an echoed nonce, messages before authentication, the published transcript test vector;\n- full payments between two `DirectPayClient`s over loopback WebRTC, through the in-memory hub\n  and through a real relay over `ws`, and by code exchange: a 2 MiB transaction, acks, the\n  payee dropping mid-payment, duplicate and concurrent delivery, offline and refusing payees,\n  inbound limits and intake budgets, timeouts;\n- the relay: authentication and URL binding, limits, TURN issuance from coturn REST and from\n  Cloudflare (against a mocked API: both answer shapes, failures, the 5 s timeout, no secret in\n  frames or logs), admission, liveness;\n- `MessageBoxSignaler` against a fake MessageBox client, live websocket paths included;\n- presence: knocks and rings, the 2 s presence and 6 s dial clocks, and per-dial cancel;\n- the relayed path through a real coturn 4.18.0 in Docker (`pnpm test:turn`);\n- the examples in `docs/protocol.md`, recomputed from the code (`test/protocol-doc.test.ts`);\n- guards: nothing under `src/` uses Node (`test/browser-safety.test.ts`), no public IP address\n  is published in tests or docs (`test/ip-literals.test.ts`), and the files the README links\n  to are in the package (`test/package-files.test.ts`);\n- type-level interop: `PaymentToken` and `IncomingPayment` against `@bsv/sdk`'s\n  `CreateActionResult` and `@bsv/message-box-client`'s types (the workspace packages), both\n  ways (`test/types.test.ts`, checked by `pnpm typecheck`).\n\nThe demo, `examples/demo-node.ts`, is not part of `pnpm test`; run by hand, it pays through the\nbuilt package and prints an `accepted` ack.\n\nNot verified:\n\n- any browser end to end: no Chromium, Firefox or Safari peer connected to another peer in\n  these tests. A research prototype of the compact SDP was accepted by Chromium as both offer\n  and answer; whether Chromium accepts the rebuilt `raddr ::` for IPv6 candidates is unchecked;\n- React Native;\n- real networks: the test sandbox drops UDP to non-loopback addresses, so NAT traversal and\n  Electron-to-Electron across networks are untested;\n- TURN in production: only the local coturn; no test calls Cloudflare's API or relays through\n  Cloudflare. A real MessageBox server is untested too;\n- real BRC-100 wallets other than `ProtoWallet` (`internalizeAction` is mocked in the tests).\n\n## License\n\nOpen BSV License Version 6; see [LICENSE.txt](LICENSE.txt).\n","readmeFilename":"README.md"}