@thecryptodonkey/toll-booth 3.3.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # toll-booth
2
2
 
3
+ [![CI](https://github.com/TheCryptoDonkey/toll-booth/actions/workflows/ci.yml/badge.svg)](https://github.com/TheCryptoDonkey/toll-booth/actions/workflows/ci.yml)
3
4
  [![MIT licence](https://img.shields.io/badge/licence-MIT-blue.svg)](./LICENSE)
4
5
  [![Nostr](https://img.shields.io/badge/Nostr-Zap%20me-purple)](https://primal.net/p/npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2)
5
6
  [![npm](https://img.shields.io/npm/v/@thecryptodonkey/toll-booth)](https://www.npmjs.com/package/@thecryptodonkey/toll-booth)
@@ -110,7 +111,7 @@ curl -H "Authorization: L402 <macaroon>:<preimage>" https://jokes.trotters.dev/a
110
111
 
111
112
  - **L402 protocol** - industry-standard HTTP 402 payment flow with macaroon credentials
112
113
  - **Multiple Lightning backends** - Phoenixd, LND, CLN, LNbits, NWC (any Nostr Wallet Connect wallet)
113
- - **Alternative payment methods** - Cashu ecash tokens
114
+ - **Alternative payment methods** - Cashu ecash tokens and xcashu (NUT-24) direct-header payments
114
115
  - **Cashu-only mode** - no Lightning node required; ideal for serverless and edge deployments
115
116
  - **Credit system** - pre-paid balance with volume discount tiers
116
117
  - **Free tier** - configurable daily allowance (IP-hashed, no PII stored)
@@ -241,6 +242,26 @@ const booth = new Booth({
241
242
 
242
243
  No Lightning node, no channels, no liquidity management. Ideal for serverless and edge deployments.
243
244
 
245
+ ### xcashu (Cashu ecash via NUT-24)
246
+
247
+ ```typescript
248
+ import { Booth } from '@thecryptodonkey/toll-booth'
249
+
250
+ const booth = new Booth({
251
+ adapter: 'web-standard',
252
+ xcashu: {
253
+ mints: ['https://mint.minibits.cash'],
254
+ unit: 'sat',
255
+ },
256
+ pricing: { '/api': 10 },
257
+ upstream: 'http://localhost:3000',
258
+ })
259
+ ```
260
+
261
+ Clients pay by sending `X-Cashu: cashuB...` tokens in the request header. Proofs are verified and swapped at the configured mint(s) using cashu-ts.
262
+
263
+ Unlike the `redeemCashu` callback (which integrates Cashu into the L402 payment-and-redeem flow), `xcashu` is a self-contained payment rail: the client attaches a token directly to the API request and gets access in one step — no separate redeem endpoint required. Both rails can run simultaneously; the 402 challenge will include both `WWW-Authenticate` (L402) and `X-Cashu` headers.
264
+
244
265
  ---
245
266
 
246
267
  ## Lightning backends
package/dist/booth.js CHANGED
@@ -2,6 +2,7 @@ import { normalisePricingTable } from './core/payment-rail.js';
2
2
  import { createTollBooth } from './core/toll-booth.js';
3
3
  import { createL402Rail } from './core/l402-rail.js';
4
4
  import { createX402Rail } from './core/x402-rail.js';
5
+ import { createXCashuRail } from './core/xcashu-rail.js';
5
6
  import { sqliteStorage } from './storage/sqlite.js';
6
7
  import { StatsCollector } from './stats.js';
7
8
  import { randomBytes } from 'node:crypto';
@@ -56,8 +57,8 @@ export class Booth {
56
57
  pruneTimer;
57
58
  closed = false;
58
59
  constructor(config) {
59
- if (!config.backend && !config.redeemCashu && !config.x402) {
60
- throw new Error('At least one payment method required: provide a Lightning backend, redeemCashu callback, or x402 config');
60
+ if (!config.backend && !config.redeemCashu && !config.x402 && !config.xcashu) {
61
+ throw new Error('At least one payment method required: provide a Lightning backend, redeemCashu callback, x402 config, or xcashu config');
61
62
  }
62
63
  let rootKeyInput;
63
64
  if (config.rootKey) {
@@ -97,6 +98,9 @@ export class Booth {
97
98
  if (config.x402) {
98
99
  rails.push(createX402Rail({ ...config.x402, storage: this.storage }));
99
100
  }
101
+ if (config.xcashu) {
102
+ rails.push(createXCashuRail(config.xcashu, this.storage));
103
+ }
100
104
  this.engine = createTollBooth({
101
105
  backend: config.backend,
102
106
  storage: this.storage,
@@ -108,6 +112,8 @@ export class Booth {
108
112
  freeTier: config.freeTier,
109
113
  strictPricing: config.strictPricing,
110
114
  creditTiers: config.creditTiers,
115
+ serviceName: config.serviceName,
116
+ description: config.description,
111
117
  rails,
112
118
  onPayment: (event) => {
113
119
  stats.recordPayment(event);
@@ -43,18 +43,14 @@ export async function handleCreateInvoice(deps, request) {
43
43
  if (!Number.isSafeInteger(requestedAmount) || requestedAmount < 1 || requestedAmount > 2_100_000_000_000_000) {
44
44
  return { success: false, error: 'amountSats must be a positive integer' };
45
45
  }
46
- // Find matching tier or validate amount
46
+ // Find matching tier for bonus credits, or accept custom amount at 1:1
47
47
  let creditSats = requestedAmount;
48
48
  if (deps.tiers.length > 0) {
49
49
  const tier = deps.tiers.find(t => t.amountSats === requestedAmount);
50
- if (!tier) {
51
- return {
52
- success: false,
53
- error: 'Invalid amount. Choose from available tiers.',
54
- tiers: deps.tiers,
55
- };
50
+ if (tier) {
51
+ creditSats = tier.creditSats;
56
52
  }
57
- creditSats = tier.creditSats;
53
+ // Custom amounts are accepted at 1:1 (no bonus) — no rejection
58
54
  }
59
55
  let paymentHash;
60
56
  let bolt11;
@@ -77,6 +77,19 @@ export function createTollBooth(config) {
77
77
  if (pricedEntry !== undefined && isTieredPricing(pricedEntry)) {
78
78
  challengeBody.tiers = normaliseTiersMap(pricedEntry);
79
79
  }
80
+ // Include credit purchase tiers so clients can offer volume discounts
81
+ if (config.creditTiers && config.creditTiers.length > 0) {
82
+ challengeBody.credit_tiers = config.creditTiers;
83
+ }
84
+ // Agent-friendly service metadata (only when serviceName is configured).
85
+ // auth_hint is L402-specific; x402/xcashu have different auth mechanisms.
86
+ if (config.serviceName) {
87
+ challengeBody.booth = {
88
+ name: config.serviceName,
89
+ ...(config.description && { description: config.description }),
90
+ };
91
+ challengeBody.auth_hint = 'Pay the invoice, then send header \u2014 Authorization: L402 <macaroon>:<preimage>';
92
+ }
80
93
  // Store invoice data from L402 rail if present
81
94
  const l402Data = challengeBody.l402;
82
95
  if (l402Data?.payment_hash) {
@@ -60,6 +60,8 @@ export interface TollBoothCoreConfig {
60
60
  normalisedPricing?: Record<string, PriceInfo>;
61
61
  /** Human-readable service name for invoice descriptions. Defaults to 'toll-booth'. */
62
62
  serviceName?: string;
63
+ /** Service description for 402 response bodies. */
64
+ description?: string;
63
65
  onPayment?: (event: PaymentEvent) => void;
64
66
  onRequest?: (event: RequestEvent) => void;
65
67
  onChallenge?: (event: ChallengeEvent) => void;
@@ -0,0 +1,5 @@
1
+ import type { PaymentRail } from './payment-rail.js';
2
+ import type { XCashuConfig } from '../types.js';
3
+ import type { StorageBackend } from '../storage/interface.js';
4
+ export declare function createXCashuRail(config: XCashuConfig, storage?: StorageBackend): PaymentRail;
5
+ //# sourceMappingURL=xcashu-rail.d.ts.map
@@ -0,0 +1,111 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { Wallet, getDecodedToken } from '@cashu/cashu-ts';
3
+ const FAIL = { authenticated: false, paymentId: '', mode: 'credit', currency: 'sat' };
4
+ /**
5
+ * Encode a NUT-18-style payment request for the X-Cashu header.
6
+ * Simplified JSON encoding — full CBOR encoding can be added later
7
+ * if cashu-ts exposes a PaymentRequest builder.
8
+ */
9
+ function encodePaymentRequest(amount, unit, mints) {
10
+ const payload = JSON.stringify({ a: amount, u: unit, m: mints });
11
+ return 'creqA' + Buffer.from(payload).toString('base64url');
12
+ }
13
+ export function createXCashuRail(config, storage) {
14
+ const unit = config.unit ?? 'sat';
15
+ const mintUrls = config.mints;
16
+ // Lazily initialised wallets per mint (loadMint is async, done on first use)
17
+ const wallets = new Map();
18
+ const walletReady = new Map();
19
+ async function getWallet(mintUrl) {
20
+ const existing = walletReady.get(mintUrl);
21
+ if (existing)
22
+ return existing;
23
+ const promise = (async () => {
24
+ const wallet = new Wallet(mintUrl, { unit });
25
+ await wallet.loadMint();
26
+ wallets.set(mintUrl, wallet);
27
+ return wallet;
28
+ })();
29
+ walletReady.set(mintUrl, promise);
30
+ return promise;
31
+ }
32
+ return {
33
+ type: 'xcashu',
34
+ creditSupported: true,
35
+ canChallenge(price) {
36
+ return price.sats !== undefined;
37
+ },
38
+ detect(req) {
39
+ const header = req.headers['x-cashu'];
40
+ return typeof header === 'string' && header.startsWith('cashuB');
41
+ },
42
+ async challenge(_route, price) {
43
+ const amount = price.sats;
44
+ const encoded = encodePaymentRequest(amount, unit, mintUrls);
45
+ return {
46
+ headers: { 'X-Cashu': encoded },
47
+ body: {
48
+ xcashu: { amount, unit, mints: mintUrls },
49
+ },
50
+ };
51
+ },
52
+ async verify(req) {
53
+ const header = req.headers['x-cashu'];
54
+ if (typeof header !== 'string' || !header.startsWith('cashuB')) {
55
+ return FAIL;
56
+ }
57
+ let decoded;
58
+ try {
59
+ decoded = getDecodedToken(header);
60
+ }
61
+ catch {
62
+ return FAIL;
63
+ }
64
+ // Validate mint is accepted
65
+ const tokenMint = decoded.mint;
66
+ if (!tokenMint || !mintUrls.includes(tokenMint)) {
67
+ return FAIL;
68
+ }
69
+ // Validate unit matches
70
+ if (decoded.unit && decoded.unit !== unit) {
71
+ return FAIL;
72
+ }
73
+ // Get or initialise wallet for this mint
74
+ let wallet;
75
+ try {
76
+ wallet = await getWallet(tokenMint);
77
+ }
78
+ catch {
79
+ return FAIL;
80
+ }
81
+ // Swap proofs at the mint to verify and claim them
82
+ let receivedProofs;
83
+ try {
84
+ receivedProofs = await wallet.receive(header);
85
+ }
86
+ catch {
87
+ // Mint unreachable, proofs already spent, or other error
88
+ return FAIL;
89
+ }
90
+ const creditedAmount = receivedProofs.reduce((sum, p) => sum + p.amount, 0);
91
+ if (creditedAmount <= 0) {
92
+ return FAIL;
93
+ }
94
+ // Generate payment ID and settlement secret
95
+ const paymentId = randomBytes(32).toString('hex');
96
+ const settlementSecret = randomBytes(32).toString('hex');
97
+ // Settle credit if storage available
98
+ if (storage && !storage.isSettled(paymentId)) {
99
+ storage.settleWithCredit(paymentId, creditedAmount, settlementSecret, unit);
100
+ }
101
+ return {
102
+ authenticated: true,
103
+ paymentId,
104
+ mode: 'credit',
105
+ currency: unit,
106
+ creditBalance: creditedAmount,
107
+ };
108
+ },
109
+ };
110
+ }
111
+ //# sourceMappingURL=xcashu-rail.js.map
package/dist/index.d.ts CHANGED
@@ -27,6 +27,8 @@ export type { L402RailConfig } from './core/l402-rail.js';
27
27
  export { createX402Rail } from './core/x402-rail.js';
28
28
  export type { X402RailConfig, X402Facilitator, X402Payment, X402VerifyResult } from './core/x402-types.js';
29
29
  export { DEFAULT_USDC_ASSETS } from './core/x402-types.js';
30
+ export { createXCashuRail } from './core/xcashu-rail.js';
31
+ export type { XCashuConfig } from './types.js';
30
32
  export { mintMacaroon, verifyMacaroon, parseCaveats } from './macaroon.js';
31
33
  export type { VerifyContext, VerifyResult } from './macaroon.js';
32
34
  export { FreeTier, CreditFreeTier } from './free-tier.js';
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { normalisePricing, normalisePricingTable, isTieredPricing } from './core
17
17
  export { createL402Rail } from './core/l402-rail.js';
18
18
  export { createX402Rail } from './core/x402-rail.js';
19
19
  export { DEFAULT_USDC_ASSETS } from './core/x402-types.js';
20
+ export { createXCashuRail } from './core/xcashu-rail.js';
20
21
  // Utilities
21
22
  export { mintMacaroon, verifyMacaroon, parseCaveats } from './macaroon.js';
22
23
  export { FreeTier, CreditFreeTier } from './free-tier.js';
package/dist/types.d.ts CHANGED
@@ -66,12 +66,21 @@ export interface CreditTier {
66
66
  creditSats: number;
67
67
  /** Human-readable label for this tier. */
68
68
  label: string;
69
- /** Pricing tier this credit tier belongs to (e.g. 'default', 'premium'). */
70
- tier?: string;
71
69
  /** x402 tier amount in cents (USD). */
72
70
  amountUsd?: number;
73
71
  /** x402 tier credit in cents (USD). */
74
72
  creditUsd?: number;
73
+ /** What the agent gets for this tier, e.g. "1 request", "10 minutes access". */
74
+ yields?: string;
75
+ }
76
+ /**
77
+ * Configuration for the xcashu (NUT-24) payment rail.
78
+ */
79
+ export interface XCashuConfig {
80
+ /** Accepted Cashu mint URLs (1+) */
81
+ mints: string[];
82
+ /** Currency unit, default 'sat' */
83
+ unit?: Currency;
75
84
  }
76
85
  /**
77
86
  * Configuration for a toll-booth instance.
@@ -158,12 +167,22 @@ export interface BoothConfig {
158
167
  redeemCashu?: (token: string, paymentHash: string) => Promise<number>;
159
168
  /** x402 stablecoin payment rail configuration. */
160
169
  x402?: X402RailConfig;
170
+ /**
171
+ * xcashu (NUT-24) config — accept Cashu ecash via X-Cashu header.
172
+ * Proofs are swapped at the configured mint(s) using cashu-ts.
173
+ */
174
+ xcashu?: XCashuConfig;
161
175
  /**
162
176
  * Human-readable service name used in Lightning invoice descriptions.
163
177
  * Defaults to `'toll-booth'`. Example: `'satgate'` produces invoices
164
178
  * like `"satgate: 1000 sats credit"`.
165
179
  */
166
180
  serviceName?: string;
181
+ /**
182
+ * Service description shown in 402 response bodies.
183
+ * Only included when `serviceName` is also set.
184
+ */
185
+ description?: string;
167
186
  /**
168
187
  * Timeout in milliseconds for upstream proxy requests.
169
188
  * Defaults to 30000 (30 seconds).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thecryptodonkey/toll-booth",
3
- "version": "3.3.1",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "description": "Monetise any API with HTTP 402 payments. Payment-rail agnostic middleware for Express, Hono, Deno, Bun, and Workers.",
6
6
  "license": "MIT",
@@ -103,6 +103,7 @@
103
103
  "prepare": "patch-package"
104
104
  },
105
105
  "dependencies": {
106
+ "@cashu/cashu-ts": "^3.6.0",
106
107
  "better-sqlite3": "^11.8.0",
107
108
  "macaroon": "^3.0.4",
108
109
  "nostr-core": "^0.4.0",
@@ -121,7 +122,6 @@
121
122
  }
122
123
  },
123
124
  "devDependencies": {
124
- "@cashu/cashu-ts": "^3.5.0",
125
125
  "@hono/node-server": "^1.19.11",
126
126
  "@semantic-release/changelog": "^6.0.3",
127
127
  "@semantic-release/git": "^10.0.1",
@@ -136,6 +136,5 @@
136
136
  "tsx": "^4.21.0",
137
137
  "typescript": "^5.7.0",
138
138
  "vitest": "^3.0.0"
139
- },
140
- "optionalDependencies": {}
139
+ }
141
140
  }