diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8662623 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# AGENTS.md + +This file defines stable, repository-level guidance for future coding agents. + +## Primary Goal +- Maintain and extend LNI bindings with consistent API behavior across languages. +- Current priority for TypeScript handoff: implement Spark support in `bindings/typescript`. + +## Spark Implementation Guidance (TypeScript) +- Treat Rust Spark implementation as source of truth: + - `crates/lni/spark/api.rs` + - `crates/lni/spark/lib.rs` + - `crates/lni/spark/types.rs` +- Match existing TypeScript node conventions: + - Implement class in `bindings/typescript/src/nodes/`. + - Use shared types from `bindings/typescript/src/types.ts`. + - Reuse helpers from `bindings/typescript/src/internal/*`. + - Export from `bindings/typescript/src/index.ts`. + +## Test/Validation Expectations +- Prefer real integration-style tests over mocks for node adapters. +- Add/maintain node-specific integration tests under: + - `bindings/typescript/src/__tests__/integration/` +- Ensure `npm run typecheck` passes. +- Ensure `npm run pack:dry-run` succeeds and does not include tests/secrets. + +## Security Expectations +- Never commit credentials or local machine details. +- Do not print sensitive runtime values in tests/logging: + - API keys, macaroons, runes, passwords, NWC URIs, full invoices/preimages. +- Keep examples sanitized. + +## Handoff Context +- High-level sanitized thread context is in: + - `docs/THREAD_CONTEXT.md` diff --git a/bindings/typescript/.gitignore b/bindings/typescript/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/bindings/typescript/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md new file mode 100644 index 0000000..1b0c491 --- /dev/null +++ b/bindings/typescript/README.md @@ -0,0 +1,133 @@ +# Lightning Node Interface + +Remote connect to major Lightning node implementations with one TypeScript interface. + +- Supports major nodes: CLN, LND, Phoenixd +- Supports protocols: BOLT11, BOLT12, NWC +- Includes custodial APIs: Strike, Speed, Blink +- LNURL + Lightning Address support (`user@domain.com`, `lnurl1...`) +- Frontend-capable TypeScript runtime (`fetch`-based) + +## Install + +```bash +npm install @sunnyln/lni +``` + +## TypeScript Examples + +### Node API + +```ts +import { + createNode, + InvoiceType, + type BackendNodeConfig, +} from '@sunnyln/lni'; + +const backend: BackendNodeConfig = { + kind: 'lnd', + config: { + url: 'https://lnd.example.com', + macaroon: '...', + }, +}; + +const node = createNode(backend); + +const info = await node.getInfo(); + +const invoiceParams = { + invoiceType: InvoiceType.Bolt11, + amountMsats: 2000, + description: 'your memo', + expiry: 3600, +}; + +const invoice = await node.createInvoice(invoiceParams); + +const payInvoiceParams = { + invoice: invoice.invoice, + feeLimitPercentage: 1, + allowSelfPayment: true, +}; + +const payment = await node.payInvoice(payInvoiceParams); + +const status = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); + +const txs = await node.listTransactions({ from: 0, limit: 10 }); +``` + +For NWC specifically, `createNode` returns `NwcNode` when `kind: 'nwc'`, so you can close it: + +```ts +const nwcNode = createNode({ kind: 'nwc', config: { nwcUri: 'nostr+walletconnect://...' } }); +// ... use node +nwcNode.close(); +``` + +### LNURL + Lightning Address + +```ts +import { detectPaymentType, needsResolution, getPaymentInfo, resolveToBolt11 } from '@sunnyln/lni'; + +const destination = 'user@domain.com'; + +const type = detectPaymentType(destination); +const requiresResolution = needsResolution(destination); +const info = await getPaymentInfo(destination, 100_000); +const bolt11 = await resolveToBolt11(destination, 100_000); +``` + +### Invoice Event Polling + +```ts +await node.onInvoiceEvents( + { + paymentHash: invoice.paymentHash, + pollingDelaySec: 3, + maxPollingSec: 60, + }, + (status, tx) => { + console.log('Invoice event:', status, tx); + }, +); +``` + +## Implemented in this package + +- `PhoenixdNode` +- `ClnNode` +- `LndNode` +- `NwcNode` +- `StrikeNode` +- `SpeedNode` +- `BlinkNode` +- LNURL helpers (`detectPaymentType`, `needsResolution`, `resolveToBolt11`, `getPaymentInfo`) + +Not included yet: +- `SparkNode` (planned) + +## Frontend Runtime Notes + +- Uses `fetch`, no Node-native runtime dependency required. +- You can inject custom fetch via constructor options: + - `new LndNode(config, { fetch: customFetch })` +- Most backends require secrets (API keys, macaroons, runes, passwords). For production web apps, use a backend proxy/BFF to protect credentials. + +## Build and Publish (package maintainers) + +```bash +npm run prepack +npm run pack:dry-run +npm run publish:public +``` + +## Integration tests + +```bash +npm run test:integration +``` + +These scripts set `NODE_TLS_REJECT_UNAUTHORIZED=0` because many local Lightning nodes use self-signed certs in test environments. Do not use this in production. diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json new file mode 100644 index 0000000..ab224d4 --- /dev/null +++ b/bindings/typescript/package-lock.json @@ -0,0 +1,1824 @@ +{ + "name": "@sunnyln/lni", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sunnyln/lni", + "version": "0.1.0", + "dependencies": { + "@getalby/sdk": "^7.0.0", + "@scure/base": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.5", + "typescript": "^5.7.3", + "vitest": "^2.1.8", + "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@getalby/lightning-tools": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-6.1.0.tgz", + "integrity": "sha512-rGurar9X4Gm+9xwoNYS8s9YLK7ZYqvbqv4KbHLYV0LEeB0HxZHRgmxblGqg+fYfp6iiYHx+edIgUpt9rS3VwFw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-7.0.0.tgz", + "integrity": "sha512-0c8gyvFbRDHZIgHmOD/dfyPukxZLeidx/hx7SXlMIS/hsx4mXpKpo9Gx1zW90buElnd3k9TVB/S/bnFSEZPE7w==", + "license": "MIT", + "dependencies": { + "@getalby/lightning-tools": "^6.0.0", + "nostr-tools": "^2.17.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", + "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dev": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dev": true, + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nostr-tools": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.0.tgz", + "integrity": "sha512-TcjR+HOxzf3sceLo9ceFekCwaQEamigaPllG7LTu3dLkJiPTw5vF0ekO8n7msWUG/G4D9cV8aqpoR0M3L9Bjwg==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tstl": { + "version": "2.5.16", + "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz", + "integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket-polyfill": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", + "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", + "dev": true, + "dependencies": { + "tstl": "^2.0.7", + "websocket": "^1.0.28" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + } + } +} diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json new file mode 100644 index 0000000..08137e0 --- /dev/null +++ b/bindings/typescript/package.json @@ -0,0 +1,70 @@ +{ + "name": "@sunnyln/lni", + "version": "0.1.2", + "private": false, + "description": "Frontend-first TypeScript port of LNI lightning wallet HTTP adapters", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/lightning-node-interface/lni.git", + "directory": "bindings/typescript" + }, + "homepage": "https://github.com/lightning-node-interface/lni/tree/master/bindings/typescript", + "bugs": { + "url": "https://github.com/lightning-node-interface/lni/issues" + }, + "keywords": [ + "lightning", + "bitcoin", + "lnurl", + "nwc", + "typescript" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "prepack": "npm run clean && npm run typecheck && npm run build", + "pack:dry-run": "npm pack --dry-run", + "publish:public": "npm publish --access public", + "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/*.real.test.ts", + "test:integration:phoenixd": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/phoenixd.real.test.ts", + "test:integration:cln": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/cln.real.test.ts", + "test:integration:lnd": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/lnd.real.test.ts", + "test:integration:strike": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/strike.real.test.ts", + "test:integration:speed": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/speed.real.test.ts", + "test:integration:blink": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/blink.real.test.ts", + "test:integration:nwc": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/nwc.real.test.ts" + }, + "dependencies": { + "@getalby/sdk": "^7.0.0", + "@scure/base": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.5", + "typescript": "^5.7.3", + "vitest": "^2.1.8", + "websocket-polyfill": "^0.0.3" + } +} diff --git a/bindings/typescript/src/__tests__/integration/blink.real.test.ts b/bindings/typescript/src/__tests__/integration/blink.real.test.ts new file mode 100644 index 0000000..cfc8b91 --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/blink.real.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from 'vitest'; +import { BlinkNode } from '../../nodes/blink.js'; +import { hasEnv, itIf, nonEmpty, runOrSkipKnownError, testInvoiceLabel, timeout, uniqueValues } from './helpers.js'; + +describe('Real integration from crates/lni/.env > BlinkNode', () => { + const enabled = hasEnv('BLINK_API_KEY'); + + const makeNode = () => + new BlinkNode({ + apiKey: process.env.BLINK_API_KEY!, + baseUrl: nonEmpty(process.env.BLINK_BASE_URL), + }); + + itIf(enabled)('getInfo + createInvoice + listTransactions', async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.alias).toBe('string'); + + const invoice = await node.createInvoice({ + amountMsats: 5_000, + description: testInvoiceLabel('blink'), + }); + console.log('Blink Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + + const txs = await node.listTransactions({ from: 0, limit: 25 }); + expect(Array.isArray(txs)).toBe(true); + }, timeout); + + itIf(enabled)('lookupInvoice (best effort from env or recent tx)', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const txs = await node.listTransactions({ from: 0, limit: 50 }); + const candidateHash = txs.find((tx) => tx.paymentHash.length > 0)?.paymentHash; + const hashes = uniqueValues([process.env.BLINK_TEST_PAYMENT_HASH, candidateHash]); + + if (!hashes.length) { + return; + } + + let lastError: unknown; + for (const paymentHash of hashes) { + try { + const tx = await node.lookupInvoice({ paymentHash }); + expect(tx.paymentHash.length).toBeGreaterThan(0); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + }, ['transaction not found', 'http 404']); + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/cln.real.test.ts b/bindings/typescript/src/__tests__/integration/cln.real.test.ts new file mode 100644 index 0000000..f0ba41d --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/cln.real.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from 'vitest'; +import { ClnNode } from '../../nodes/cln.js'; +import { hasEnv, itIf, testInvoiceLabel, timeout } from './helpers.js'; + +describe('Real integration from crates/lni/.env > ClnNode', () => { + const enabled = hasEnv('CLN_URL', 'CLN_RUNE'); + + const makeNode = () => + new ClnNode({ + url: process.env.CLN_URL!, + rune: process.env.CLN_RUNE!, + }); + + itIf(enabled)('getInfo', async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.pubkey).toBe('string'); + expect(info.pubkey.length).toBeGreaterThan(0); + }, timeout); + + itIf(enabled)('createInvoice + lookupInvoice + listTransactions', async () => { + const node = makeNode(); + const invoice = await node.createInvoice({ + amountMsats: 2_000, + description: testInvoiceLabel('cln'), + }); + console.log('CLN Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + expect(invoice.paymentHash.length).toBeGreaterThan(0); + + const lookedUp = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); + expect(lookedUp.paymentHash).toBe(invoice.paymentHash); + + const txs = await node.listTransactions({ from: 0, limit: 25, paymentHash: invoice.paymentHash }); + expect(Array.isArray(txs)).toBe(true); + expect(txs.some((tx) => tx.paymentHash === invoice.paymentHash)).toBe(true); + }, timeout); + + itIf(enabled && hasEnv('CLN_TEST_PAYMENT_REQUEST'))('decode', async () => { + const node = makeNode(); + const decoded = await node.decode(process.env.CLN_TEST_PAYMENT_REQUEST!); + expect(decoded.length).toBeGreaterThan(0); + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/helpers.ts b/bindings/typescript/src/__tests__/integration/helpers.ts new file mode 100644 index 0000000..efda99c --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/helpers.ts @@ -0,0 +1,57 @@ +import { it } from 'vitest'; +import 'websocket-polyfill'; + +export const timeout = 120_000; +type ConditionalIt = (name: string, fn: () => Promise | void, timeout?: number) => void; +export const itIf = (condition: boolean): ConditionalIt => (condition ? it : it.skip); + +export const hasEnv = (...keys: string[]): boolean => + keys.every((key) => Boolean(process.env[key]?.trim())); + +export const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +export function uniqueValues(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const value of values) { + const normalized = nonEmpty(value); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + + return result; +} + +export function testInvoiceLabel(prefix: string): string { + return `${prefix} ts integration ${Date.now()}`; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isKnownError(error: unknown, patterns: string[]): boolean { + const message = errorMessage(error).toLowerCase(); + return patterns.some((pattern) => message.includes(pattern.toLowerCase())); +} + +export async function runOrSkipKnownError(action: () => Promise, patterns: string[]): Promise { + try { + await action(); + } catch (error) { + if (isKnownError(error, patterns)) { + return; + } + throw error; + } +} diff --git a/bindings/typescript/src/__tests__/integration/lnd.real.test.ts b/bindings/typescript/src/__tests__/integration/lnd.real.test.ts new file mode 100644 index 0000000..1670dd1 --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/lnd.real.test.ts @@ -0,0 +1,45 @@ +import { describe, expect } from 'vitest'; +import { LndNode } from '../../nodes/lnd.js'; +import { hasEnv, itIf, runOrSkipKnownError, testInvoiceLabel, timeout } from './helpers.js'; + +describe('Real integration from crates/lni/.env > LndNode', () => { + const enabled = hasEnv('LND_URL', 'LND_MACAROON'); + + const makeNode = () => + new LndNode({ + url: process.env.LND_URL!, + macaroon: process.env.LND_MACAROON!, + }); + + itIf(enabled)('getInfo', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.pubkey).toBe('string'); + expect(info.pubkey.length).toBeGreaterThan(0); + }, ['permission denied']); + }, timeout); + + itIf(enabled)('createInvoice + lookupInvoice + listTransactions', async () => { + const node = makeNode(); + const invoice = await node.createInvoice({ + amountMsats: 3_000, + description: testInvoiceLabel('lnd'), + }); + console.log('LND Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + expect(invoice.paymentHash.length).toBeGreaterThan(0); + + const lookedUp = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); + expect(lookedUp.paymentHash.length).toBeGreaterThan(0); + + const txs = await node.listTransactions({ from: 0, limit: 25, paymentHash: invoice.paymentHash }); + expect(Array.isArray(txs)).toBe(true); + }, timeout); + + itIf(enabled && hasEnv('LND_TEST_PAYMENT_REQUEST'))('decode', async () => { + const node = makeNode(); + const decoded = await node.decode(process.env.LND_TEST_PAYMENT_REQUEST!); + expect(decoded.length).toBeGreaterThan(0); + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/nwc.real.test.ts b/bindings/typescript/src/__tests__/integration/nwc.real.test.ts new file mode 100644 index 0000000..b4c46a0 --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/nwc.real.test.ts @@ -0,0 +1,37 @@ +import { describe, expect } from 'vitest'; +import { NwcNode } from '../../nodes/nwc.js'; +import { hasEnv, itIf, testInvoiceLabel, timeout } from './helpers.js'; + +describe('Real integration from crates/lni/.env > NwcNode', () => { + const enabled = hasEnv('NWC_URI'); + + const makeNode = () => new NwcNode({ nwcUri: process.env.NWC_URI! }); + + itIf(enabled)('getInfo + createInvoice + listTransactions + lookupInvoice', async () => { + const node = makeNode(); + try { + const info = await node.getInfo(); + expect(typeof info.alias).toBe('string'); + + const invoice = await node.createInvoice({ + amountMsats: 3_000, + description: testInvoiceLabel('nwc'), + }); + console.log('NWC Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + + const txs = await node.listTransactions({ from: 0, limit: 25 }); + expect(Array.isArray(txs)).toBe(true); + + if (invoice.paymentHash.length > 0) { + const hashLookup = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); + expect(hashLookup.paymentHash.length).toBeGreaterThan(0); + } + + const invoiceLookup = await node.lookupInvoice({ search: invoice.invoice }); + expect(typeof invoiceLookup.type).toBe('string'); + } finally { + node.close(); + } + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts b/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts new file mode 100644 index 0000000..36309b9 --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/phoenixd.real.test.ts @@ -0,0 +1,41 @@ +import { describe, expect } from 'vitest'; +import { PhoenixdNode } from '../../nodes/phoenixd.js'; +import { hasEnv, itIf, runOrSkipKnownError, testInvoiceLabel, timeout } from './helpers.js'; + +describe('Real integration from crates/lni/.env > PhoenixdNode', () => { + const enabled = hasEnv('PHOENIXD_URL', 'PHOENIXD_PASSWORD'); + + const makeNode = () => + new PhoenixdNode({ + url: process.env.PHOENIXD_URL!, + password: process.env.PHOENIXD_PASSWORD!, + }); + + itIf(enabled)('getInfo', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.pubkey).toBe('string'); + expect(info.pubkey.length).toBeGreaterThan(0); + }, ['fetch failed', 'econnrefused', 'enotfound', 'timed out']); + }, timeout); + + itIf(enabled)('createInvoice + lookupInvoice + listTransactions', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const invoice = await node.createInvoice({ + amountMsats: 2_000, + description: testInvoiceLabel('phoenixd'), + }); + console.log('Phoenixd Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + expect(invoice.paymentHash.length).toBeGreaterThan(0); + + const lookedUp = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); + expect(lookedUp.paymentHash).toBe(invoice.paymentHash); + + const txs = await node.listTransactions({ from: 0, limit: 25, paymentHash: invoice.paymentHash }); + expect(Array.isArray(txs)).toBe(true); + }, ['fetch failed', 'econnrefused', 'enotfound', 'timed out']); + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/speed.real.test.ts b/bindings/typescript/src/__tests__/integration/speed.real.test.ts new file mode 100644 index 0000000..943ab35 --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/speed.real.test.ts @@ -0,0 +1,58 @@ +import { describe, expect } from 'vitest'; +import { SpeedNode } from '../../nodes/speed.js'; +import { hasEnv, itIf, nonEmpty, runOrSkipKnownError, testInvoiceLabel, timeout, uniqueValues } from './helpers.js'; + +describe('Real integration from crates/lni/.env > SpeedNode', () => { + const enabled = hasEnv('SPEED_API_KEY'); + + const makeNode = () => + new SpeedNode({ + apiKey: process.env.SPEED_API_KEY!, + baseUrl: nonEmpty(process.env.SPEED_BASE_URL), + }); + + itIf(enabled)('getInfo + createInvoice + listTransactions', async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.alias).toBe('string'); + + const invoice = await node.createInvoice({ + amountMsats: 5_000, + description: testInvoiceLabel('speed'), + }); + console.log('Speed Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + + const txs = await node.listTransactions({ from: 0, limit: 25 }); + expect(Array.isArray(txs)).toBe(true); + }, timeout); + + itIf(enabled)('lookupInvoice by search (best effort from env or recent tx)', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const txs = await node.listTransactions({ from: 0, limit: 50 }); + const candidateSearch = txs.find((tx) => tx.invoice.length > 0)?.invoice; + const searches = uniqueValues([process.env.SPEED_TEST_PAYMENT_REQUEST, candidateSearch]); + + if (!searches.length) { + return; + } + + let lastError: unknown; + for (const search of searches) { + try { + const tx = await node.lookupInvoice({ search }); + expect(typeof tx.type).toBe('string'); + expect(tx.type.length).toBeGreaterThan(0); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + }, ['no transactions found']); + }, timeout); +}); diff --git a/bindings/typescript/src/__tests__/integration/strike.real.test.ts b/bindings/typescript/src/__tests__/integration/strike.real.test.ts new file mode 100644 index 0000000..c3a999e --- /dev/null +++ b/bindings/typescript/src/__tests__/integration/strike.real.test.ts @@ -0,0 +1,58 @@ +import { describe, expect } from 'vitest'; +import { StrikeNode } from '../../nodes/strike.js'; +import { hasEnv, itIf, nonEmpty, runOrSkipKnownError, testInvoiceLabel, timeout, uniqueValues } from './helpers.js'; + +describe('Real integration from crates/lni/.env > StrikeNode', () => { + const enabled = hasEnv('STRIKE_API_KEY'); + + const makeNode = () => + new StrikeNode({ + apiKey: process.env.STRIKE_API_KEY!, + baseUrl: nonEmpty(process.env.STRIKE_BASE_URL), + }); + + itIf(enabled)('getInfo + createInvoice + listTransactions', async () => { + const node = makeNode(); + const info = await node.getInfo(); + expect(typeof info.alias).toBe('string'); + + const invoice = await node.createInvoice({ + amountMsats: 5_000, + description: testInvoiceLabel('strike'), + }); + console.log('Strike Invoice:', invoice); + expect(invoice.invoice.length).toBeGreaterThan(0); + expect(invoice.paymentHash.length).toBeGreaterThan(0); + + const txs = await node.listTransactions({ from: 0, limit: 25 }); + expect(Array.isArray(txs)).toBe(true); + }, timeout); + + itIf(enabled)('lookupInvoice (best effort from env or recent tx)', async () => { + await runOrSkipKnownError(async () => { + const node = makeNode(); + const txs = await node.listTransactions({ from: 0, limit: 50 }); + const candidateHash = txs.find((tx) => tx.paymentHash.length > 0)?.paymentHash; + const lookupHashes = uniqueValues([process.env.STRIKE_TEST_PAYMENT_HASH, candidateHash]); + + if (!lookupHashes.length) { + return; + } + + let lastError: unknown; + for (const paymentHash of lookupHashes) { + try { + const tx = await node.lookupInvoice({ paymentHash }); + expect(tx.paymentHash.length).toBeGreaterThan(0); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + }, ['no receive found', 'http 404']); + }, timeout); +}); diff --git a/bindings/typescript/src/errors.ts b/bindings/typescript/src/errors.ts new file mode 100644 index 0000000..f291071 --- /dev/null +++ b/bindings/typescript/src/errors.ts @@ -0,0 +1,33 @@ +export type LniErrorCode = + | 'Http' + | 'Api' + | 'Json' + | 'NetworkError' + | 'InvalidInput' + | 'LnurlError'; + +export class LniError extends Error { + public readonly code: LniErrorCode; + public readonly status?: number; + public readonly body?: string; + + constructor(code: LniErrorCode, message: string, options?: { status?: number; body?: string; cause?: unknown }) { + super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'LniError'; + this.code = code; + this.status = options?.status; + this.body = options?.body; + } +} + +export function asLniError(error: unknown, fallbackCode: LniErrorCode = 'Api'): LniError { + if (error instanceof LniError) { + return error; + } + + if (error instanceof Error) { + return new LniError(fallbackCode, error.message, { cause: error }); + } + + return new LniError(fallbackCode, 'Unknown error', { cause: error }); +} diff --git a/bindings/typescript/src/factory.ts b/bindings/typescript/src/factory.ts new file mode 100644 index 0000000..37cc2f0 --- /dev/null +++ b/bindings/typescript/src/factory.ts @@ -0,0 +1,67 @@ +import { BlinkNode } from './nodes/blink.js'; +import { ClnNode } from './nodes/cln.js'; +import { LndNode } from './nodes/lnd.js'; +import { NwcNode } from './nodes/nwc.js'; +import { PhoenixdNode } from './nodes/phoenixd.js'; +import { SpeedNode } from './nodes/speed.js'; +import { StrikeNode } from './nodes/strike.js'; +import type { + BackendNodeConfig, + BlinkConfig, + ClnConfig, + LightningNode, + LndConfig, + NodeRequestOptions, + NwcConfig, + PhoenixdConfig, + SpeedConfig, + StrikeConfig, +} from './types.js'; + +export function createNode( + input: { kind: 'phoenixd'; config: PhoenixdConfig }, + options?: NodeRequestOptions, +): PhoenixdNode; +export function createNode( + input: { kind: 'cln'; config: ClnConfig }, + options?: NodeRequestOptions, +): ClnNode; +export function createNode( + input: { kind: 'lnd'; config: LndConfig }, + options?: NodeRequestOptions, +): LndNode; +export function createNode( + input: { kind: 'nwc'; config: NwcConfig }, + options?: NodeRequestOptions, +): NwcNode; +export function createNode( + input: { kind: 'strike'; config: StrikeConfig }, + options?: NodeRequestOptions, +): StrikeNode; +export function createNode( + input: { kind: 'speed'; config: SpeedConfig }, + options?: NodeRequestOptions, +): SpeedNode; +export function createNode( + input: { kind: 'blink'; config: BlinkConfig }, + options?: NodeRequestOptions, +): BlinkNode; +export function createNode(input: BackendNodeConfig, options?: NodeRequestOptions): LightningNode; +export function createNode(input: BackendNodeConfig, options: NodeRequestOptions = {}): LightningNode { + switch (input.kind) { + case 'phoenixd': + return new PhoenixdNode(input.config, options); + case 'cln': + return new ClnNode(input.config, options); + case 'lnd': + return new LndNode(input.config, options); + case 'nwc': + return new NwcNode(input.config, options); + case 'strike': + return new StrikeNode(input.config, options); + case 'speed': + return new SpeedNode(input.config, options); + case 'blink': + return new BlinkNode(input.config, options); + } +} diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts new file mode 100644 index 0000000..403723f --- /dev/null +++ b/bindings/typescript/src/index.ts @@ -0,0 +1,12 @@ +export * from './types.js'; +export * from './errors.js'; +export * from './lnurl.js'; +export * from './factory.js'; + +export { PhoenixdNode } from './nodes/phoenixd.js'; +export { ClnNode } from './nodes/cln.js'; +export { LndNode } from './nodes/lnd.js'; +export { NwcNode } from './nodes/nwc.js'; +export { StrikeNode } from './nodes/strike.js'; +export { SpeedNode } from './nodes/speed.js'; +export { BlinkNode } from './nodes/blink.js'; diff --git a/bindings/typescript/src/internal/encoding.ts b/bindings/typescript/src/internal/encoding.ts new file mode 100644 index 0000000..634994b --- /dev/null +++ b/bindings/typescript/src/internal/encoding.ts @@ -0,0 +1,98 @@ +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export function encodeBase64(input: string): string { + if (typeof globalThis.btoa === 'function') { + return globalThis.btoa(input); + } + + const maybeBuffer = (globalThis as { Buffer?: { from(value: string, encoding?: string): { toString(enc: string): string } } }).Buffer; + if (maybeBuffer) { + return maybeBuffer.from(input, 'utf8').toString('base64'); + } + + const bytes = new TextEncoder().encode(input); + let output = ''; + + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i] ?? 0; + const b = bytes[i + 1] ?? 0; + const c = bytes[i + 2] ?? 0; + + const triple = (a << 16) | (b << 8) | c; + + output += BASE64_CHARS[(triple >> 18) & 63]; + output += BASE64_CHARS[(triple >> 12) & 63]; + output += i + 1 < bytes.length ? BASE64_CHARS[(triple >> 6) & 63] : '='; + output += i + 2 < bytes.length ? BASE64_CHARS[triple & 63] : '='; + } + + return output; +} + +export function decodeBase64(input: string): Uint8Array { + const normalized = input.replace(/\s+/g, ''); + + if (typeof globalThis.atob === 'function') { + const raw = globalThis.atob(normalized); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) { + out[i] = raw.charCodeAt(i); + } + return out; + } + + const maybeBuffer = (globalThis as { Buffer?: { from(value: string, encoding?: string): { values(): Iterable } } }).Buffer; + if (maybeBuffer) { + return Uint8Array.from(maybeBuffer.from(normalized, 'base64').values()); + } + + const padding = (4 - (normalized.length % 4 || 4)) % 4; + const base64 = normalized + '='.repeat(padding); + let bits = 0; + let bitCount = 0; + const output: number[] = []; + + for (const char of base64) { + if (char === '=') { + break; + } + + const value = BASE64_CHARS.indexOf(char); + if (value === -1) { + throw new Error(`Invalid base64 character: ${char}`); + } + + bits = (bits << 6) | value; + bitCount += 6; + + if (bitCount >= 8) { + bitCount -= 8; + output.push((bits >> bitCount) & 0xff); + } + } + + return Uint8Array.from(output); +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((value) => value.toString(16).padStart(2, '0')) + .join(''); +} + +export function hexToBytes(hex: string): Uint8Array { + const normalized = hex.trim().toLowerCase(); + if (normalized.length % 2 !== 0) { + throw new Error('Invalid hex length'); + } + + if (!/^[0-9a-f]*$/.test(normalized)) { + throw new Error('Invalid hex characters'); + } + + const out = new Uint8Array(normalized.length / 2); + for (let i = 0; i < normalized.length; i += 2) { + out[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16); + } + return out; +} diff --git a/bindings/typescript/src/internal/http.ts b/bindings/typescript/src/internal/http.ts new file mode 100644 index 0000000..3723560 --- /dev/null +++ b/bindings/typescript/src/internal/http.ts @@ -0,0 +1,191 @@ +import { LniError } from '../errors.js'; +import type { FetchLike } from '../types.js'; + +export type QueryValue = string | number | boolean | null | undefined; + +export interface RequestArgs { + method?: string; + headers?: HeadersInit; + query?: Record; + json?: unknown; + form?: Record; + body?: BodyInit | null; + timeoutMs?: number; + signal?: AbortSignal; +} + +export function resolveFetch(customFetch?: FetchLike): FetchLike { + if (customFetch) { + return customFetch; + } + + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch.bind(globalThis); + } + + throw new LniError('InvalidInput', 'No fetch implementation found. Pass fetch via NodeRequestOptions.'); +} + +export function buildUrl(baseUrl: string, path: string, query?: Record): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const normalizedPath = path.startsWith('/') ? path.slice(1) : path; + const url = new URL(normalizedPath, normalizedBase); + + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') { + continue; + } + url.searchParams.set(key, String(value)); + } + } + + return url.toString(); +} + +function encodeForm(form: Record): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(form)) { + if (value === undefined || value === null) { + continue; + } + params.set(key, String(value)); + } + return params; +} + +interface TimeoutSignal { + signal: AbortSignal | undefined; + clear: () => void; +} + +function withTimeout(signal: AbortSignal | undefined, timeoutMs: number | undefined): TimeoutSignal { + if (!timeoutMs || timeoutMs <= 0) { + return { + signal, + clear: () => {}, + }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + let externalAbortListener: (() => void) | undefined; + + const clear = (): void => { + clearTimeout(timeoutId); + if (signal && externalAbortListener) { + signal.removeEventListener('abort', externalAbortListener); + externalAbortListener = undefined; + } + }; + + if (signal) { + if (signal.aborted) { + clear(); + controller.abort(); + } else { + externalAbortListener = () => { + clear(); + controller.abort(); + }; + signal.addEventListener('abort', externalAbortListener, { once: true }); + } + } + + controller.signal.addEventListener( + 'abort', + () => { + clear(); + }, + { once: true }, + ); + + return { + signal: controller.signal, + clear, + }; +} + +export async function requestText(fetchFn: FetchLike, url: string, args: RequestArgs = {}): Promise { + const headers = new Headers(args.headers); + let body: BodyInit | null | undefined = args.body; + + if (args.json !== undefined) { + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + body = JSON.stringify(args.json); + } else if (args.form) { + if (!headers.has('content-type')) { + headers.set('content-type', 'application/x-www-form-urlencoded'); + } + body = encodeForm(args.form).toString(); + } + + const timeout = withTimeout(args.signal, args.timeoutMs); + + let response: Response; + try { + response = await fetchFn(url, { + method: args.method ?? (body ? 'POST' : 'GET'), + headers, + body, + signal: timeout.signal, + }); + } catch (error) { + throw new LniError('NetworkError', `Network request failed: ${(error as Error)?.message ?? 'unknown error'}`, { + cause: error, + }); + } finally { + timeout.clear(); + } + + const text = await response.text(); + + if (!response.ok) { + throw new LniError('Http', `HTTP ${response.status}: ${text || response.statusText}`, { + status: response.status, + body: text, + }); + } + + return text; +} + +export async function requestJson(fetchFn: FetchLike, url: string, args: RequestArgs = {}): Promise { + const text = await requestText(fetchFn, url, args); + + if (!text) { + return {} as T; + } + + try { + return JSON.parse(text) as T; + } catch (error) { + throw new LniError('Json', `Failed to parse JSON response: ${(error as Error)?.message ?? 'unknown error'}`, { + body: text, + cause: error, + }); + } +} + +export async function requestMaybeJson(fetchFn: FetchLike, url: string, args: RequestArgs = {}): Promise { + const text = await requestText(fetchFn, url, args); + + if (!text) { + return ''; + } + + try { + return JSON.parse(text) as T; + } catch { + return text; + } +} + +export function toTimeoutMs(timeoutSeconds?: number): number | undefined { + if (!timeoutSeconds || timeoutSeconds <= 0) { + return undefined; + } + return timeoutSeconds * 1000; +} diff --git a/bindings/typescript/src/internal/polling.ts b/bindings/typescript/src/internal/polling.ts new file mode 100644 index 0000000..65d79d2 --- /dev/null +++ b/bindings/typescript/src/internal/polling.ts @@ -0,0 +1,44 @@ +import type { InvoiceEventCallback, OnInvoiceEventParams, Transaction } from '../types.js'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export interface PollInvoiceEventsArgs { + params: OnInvoiceEventParams; + lookup: () => Promise; + callback: InvoiceEventCallback; +} + +export async function pollInvoiceEvents(args: PollInvoiceEventsArgs): Promise { + const delayMs = Math.max(args.params.pollingDelaySec, 1) * 1000; + const maxDurationMs = Math.max(args.params.maxPollingSec, 1) * 1000; + const startedAt = Date.now(); + + while (Date.now() - startedAt <= maxDurationMs) { + try { + const tx = await args.lookup(); + if (tx.settledAt > 0) { + args.callback('success', tx); + return; + } + args.callback('pending', tx); + } catch (error) { + if (typeof console !== 'undefined' && typeof console.debug === 'function') { + console.debug('[lni] pollInvoiceEvents lookup failed', error); + } + args.callback('failure'); + } + + if (Date.now() - startedAt + delayMs <= maxDurationMs) { + await sleep(delayMs); + continue; + } + + break; + } + + args.callback('failure'); +} diff --git a/bindings/typescript/src/internal/transform.ts b/bindings/typescript/src/internal/transform.ts new file mode 100644 index 0000000..1f919a6 --- /dev/null +++ b/bindings/typescript/src/internal/transform.ts @@ -0,0 +1,114 @@ +import { decodeBase64, bytesToHex } from './encoding.js'; +import type { NodeInfo, Transaction } from '../types.js'; + +export function emptyNodeInfo(overrides: Partial = {}): NodeInfo { + return { + alias: '', + color: '', + pubkey: '', + network: '', + blockHeight: 0, + blockHash: '', + sendBalanceMsat: 0, + receiveBalanceMsat: 0, + feeCreditBalanceMsat: 0, + unsettledSendBalanceMsat: 0, + unsettledReceiveBalanceMsat: 0, + pendingOpenSendBalance: 0, + pendingOpenReceiveBalance: 0, + ...overrides, + }; +} + +export function emptyTransaction(overrides: Partial = {}): Transaction { + return { + type: 'incoming', + invoice: '', + description: '', + descriptionHash: '', + preimage: '', + paymentHash: '', + amountMsats: 0, + feesPaid: 0, + createdAt: 0, + expiresAt: 0, + settledAt: 0, + ...overrides, + }; +} + +export function parseOptionalNumber(value: unknown): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +export function toUnixSeconds(value: unknown): number { + const parsed = parseOptionalNumber(value); + if (!parsed) { + return 0; + } + + if (parsed > 10_000_000_000) { + return Math.floor(parsed / 1000); + } + + return Math.floor(parsed); +} + +export function rHashToHex(value: string): string { + if (!value) { + return ''; + } + + try { + return bytesToHex(decodeBase64(value)); + } catch { + return value; + } +} + +export function btcToMsats(amount: string | number): number { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (!Number.isFinite(num)) { + return 0; + } + return Math.round(num * 100_000_000_000); +} + +export function msatsToBtc(amountMsats: number): string { + if (!Number.isFinite(amountMsats)) { + return '0.00000000'; + } + + return (amountMsats / 100_000_000_000).toFixed(8); +} + +export function satsToMsats(amount: string | number): number { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (!Number.isFinite(num)) { + return 0; + } + return Math.round(num * 1000); +} + +export function matchesSearch(tx: Transaction, search?: string): boolean { + if (!search) { + return true; + } + + const normalized = search.toLowerCase(); + return ( + tx.paymentHash.toLowerCase().includes(normalized) || + tx.description.toLowerCase().includes(normalized) || + (tx.payerNote ?? '').toLowerCase().includes(normalized) || + tx.invoice.toLowerCase().includes(normalized) + ); +} diff --git a/bindings/typescript/src/lnurl.ts b/bindings/typescript/src/lnurl.ts new file mode 100644 index 0000000..2bb1fd2 --- /dev/null +++ b/bindings/typescript/src/lnurl.ts @@ -0,0 +1,209 @@ +import { bech32 } from '@scure/base'; +import { LniError } from './errors.js'; +import type { FetchLike, PaymentInfo } from './types.js'; +import { resolveFetch, requestJson } from './internal/http.js'; + +export type PaymentDestinationType = 'bolt11' | 'bolt12' | 'lnurl' | 'lightning_address'; + +interface LnurlPayResponse { + callback: string; + maxSendable: number; + minSendable: number; + metadata: string; + tag: string; + allowsNostr?: boolean; + nostrPubkey?: string; +} + +interface LnurlInvoiceResponse { + pr: string; +} + +interface LnurlErrorResponse { + status: string; + reason: string; +} + +export function detectPaymentType(destination: string): PaymentDestinationType { + const input = destination.trim(); + const lower = input.toLowerCase(); + + if (input.includes('@') && !lower.startsWith('lnurl')) { + return 'lightning_address'; + } + if (lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lntbs')) { + return 'bolt11'; + } + if (lower.startsWith('lno1')) { + return 'bolt12'; + } + if (lower.startsWith('lnurl1')) { + return 'lnurl'; + } + + throw new LniError( + 'InvalidInput', + 'Unknown payment destination format. Expected BOLT11, BOLT12, LNURL, or Lightning Address.', + ); +} + +export function needsResolution(destination: string): boolean { + const normalized = destination.trim().toLowerCase(); + return (normalized.includes('@') && !normalized.startsWith('lnurl')) || normalized.startsWith('lnurl1'); +} + +export function lightningAddressToUrl(user: string, domain: string): string { + return `https://${domain}/.well-known/lnurlp/${user}`; +} + +export function decodeLnurl(lnurl: string): string { + try { + const decoded = bech32.decode(lnurl.toLowerCase() as `${string}1${string}`, Number.MAX_SAFE_INTEGER); + if (decoded.prefix !== 'lnurl') { + throw new LniError('InvalidInput', "LNURL must use the 'lnurl' prefix."); + } + + const bytes = Uint8Array.from(bech32.fromWords(decoded.words)); + return new TextDecoder().decode(bytes); + } catch (error) { + if (error instanceof LniError) { + throw error; + } + throw new LniError('InvalidInput', `Invalid LNURL encoding: ${(error as Error)?.message ?? 'unknown error'}`); + } +} + +async function fetchLnurlPay(url: string, fetchFn: FetchLike): Promise { + const payload = await requestJson(fetchFn, url, { + method: 'GET', + headers: { + accept: 'application/json', + }, + timeoutMs: 30_000, + }); + + const maybeError = payload as LnurlErrorResponse; + if (maybeError?.status === 'ERROR') { + throw new LniError('LnurlError', maybeError.reason); + } + + return payload as LnurlPayResponse; +} + +async function requestInvoice(callbackUrl: string, amountMsats: number, fetchFn: FetchLike): Promise { + const callback = new URL(callbackUrl); + callback.searchParams.set('amount', String(amountMsats)); + + const response = await requestJson(fetchFn, callback.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + }, + timeoutMs: 30_000, + }); + + const maybeError = response as LnurlErrorResponse; + if (maybeError.status === 'ERROR') { + throw new LniError('LnurlError', maybeError.reason); + } + + const invoiceResponse = response as LnurlInvoiceResponse; + if (!invoiceResponse.pr) { + throw new LniError('Json', 'Invalid LNURL invoice response: missing pr field'); + } + + return invoiceResponse.pr; +} + +function parseLightningAddress(input: string): { user: string; domain: string } { + const parts = input.split('@'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new LniError('InvalidInput', 'Invalid Lightning Address format.'); + } + return { user: parts[0], domain: parts[1] }; +} + +function assertAmountRange(amountMsats: number, minSendable: number, maxSendable: number): void { + if (amountMsats < minSendable) { + throw new LniError('InvalidInput', `Amount ${amountMsats} msats is below minimum ${minSendable} msats`); + } + if (amountMsats > maxSendable) { + throw new LniError('InvalidInput', `Amount ${amountMsats} msats exceeds maximum ${maxSendable} msats`); + } +} + +async function resolveViaLnurlPay(url: string, amountMsats: number, fetchFn: FetchLike): Promise { + const lnurlPay = await fetchLnurlPay(url, fetchFn); + assertAmountRange(amountMsats, lnurlPay.minSendable, lnurlPay.maxSendable); + return requestInvoice(lnurlPay.callback, amountMsats, fetchFn); +} + +export async function resolveToBolt11( + destination: string, + amountMsats?: number, + options?: { fetch?: FetchLike }, +): Promise { + const fetchFn = resolveFetch(options?.fetch); + const destinationType = detectPaymentType(destination); + + if (destinationType === 'bolt11') { + return destination.trim(); + } + + if (destinationType === 'bolt12') { + throw new LniError('InvalidInput', 'BOLT12 offers should be paid via payOffer.'); + } + + if (amountMsats === undefined || amountMsats === null) { + throw new LniError('InvalidInput', 'LNURL and Lightning Address resolution requires amountMsats.'); + } + + if (destinationType === 'lightning_address') { + const { user, domain } = parseLightningAddress(destination.trim()); + return resolveViaLnurlPay(lightningAddressToUrl(user, domain), amountMsats, fetchFn); + } + + const lnurl = decodeLnurl(destination.trim()); + return resolveViaLnurlPay(lnurl, amountMsats, fetchFn); +} + +export async function getPaymentInfo( + destination: string, + amountMsats?: number, + options?: { fetch?: FetchLike }, +): Promise { + const fetchFn = resolveFetch(options?.fetch); + const destinationType = detectPaymentType(destination); + + if (destinationType === 'bolt11' || destinationType === 'bolt12') { + return { + destinationType, + destination, + amountMsats, + }; + } + + if (destinationType === 'lightning_address') { + const { user, domain } = parseLightningAddress(destination.trim()); + const lnurlPay = await fetchLnurlPay(lightningAddressToUrl(user, domain), fetchFn); + return { + destinationType, + destination, + amountMsats, + minSendableMsats: lnurlPay.minSendable, + maxSendableMsats: lnurlPay.maxSendable, + description: lnurlPay.metadata, + }; + } + + const lnurl = decodeLnurl(destination.trim()); + const lnurlPay = await fetchLnurlPay(lnurl, fetchFn); + return { + destinationType, + destination, + amountMsats, + minSendableMsats: lnurlPay.minSendable, + maxSendableMsats: lnurlPay.maxSendable, + description: lnurlPay.metadata, + }; +} diff --git a/bindings/typescript/src/nodes/blink.ts b/bindings/typescript/src/nodes/blink.ts new file mode 100644 index 0000000..39180c2 --- /dev/null +++ b/bindings/typescript/src/nodes/blink.ts @@ -0,0 +1,505 @@ +import { LniError } from '../errors.js'; +import { requestJson, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, matchesSearch, satsToMsats } from '../internal/transform.js'; +import { InvoiceType, type BlinkConfig, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Transaction } from '../types.js'; + +interface GraphQLError { + message: string; +} + +interface GraphQLResponse { + data?: T; + errors?: GraphQLError[]; +} + +interface BlinkMeQuery { + me: { + defaultAccount: { + wallets: BlinkWallet[]; + }; + }; +} + +interface BlinkWallet { + id: string; + walletCurrency: string; + balance: number; +} + +interface BlinkInvoiceCreateResponse { + lnInvoiceCreate: { + invoice?: { + paymentRequest: string; + paymentHash: string; + satoshis: number; + }; + errors?: GraphQLError[]; + }; +} + +interface BlinkFeeProbeResponse { + lnInvoiceFeeProbe: { + amount?: number; + errors?: GraphQLError[]; + }; +} + +interface BlinkPaymentSendResponse { + lnInvoicePaymentSend: { + status: string; + errors?: GraphQLError[]; + }; +} + +interface BlinkTransactionsQuery { + me: { + defaultAccount: { + transactions: { + edges: Array<{ + cursor: string; + node: { + id: string; + createdAt: number; + direction: 'SEND' | 'RECEIVE'; + status: string; + memo?: string; + settlementAmount?: number; + settlementCurrency?: string; + settlementFee?: number; + initiationVia?: { + __typename: string; + paymentHash?: string; + }; + settlementVia?: { + __typename: string; + preImage?: string; + }; + }; + }>; + pageInfo: { + hasNextPage: boolean; + endCursor?: string | null; + }; + }; + }; + }; +} + +type BlinkTransactionNode = BlinkTransactionsQuery['me']['defaultAccount']['transactions']['edges'][number]['node']; + +interface BlinkTransactionsPage { + transactions: Transaction[]; + nextCursor: string | null; +} + +export class BlinkNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + private readonly baseUrl: string; + private cachedWalletId?: string; + private static readonly MAX_TRANSACTION_FETCH = 1000; + private static readonly DEFAULT_PAGE_SIZE = 100; + + private static readonly ME_QUERY = ` + query Me { + me { + defaultAccount { + wallets { + id + walletCurrency + balance + } + } + } + } + `; + + private static readonly TRANSACTIONS_QUERY = ` + query TransactionsQuery($first: Int, $after: String) { + me { + defaultAccount { + transactions(first: $first, after: $after) { + edges { + cursor + node { + id + createdAt + direction + status + memo + settlementAmount + settlementCurrency + settlementFee + initiationVia { + __typename + ... on InitiationViaLn { + paymentHash + } + } + settlementVia { + __typename + ... on SettlementViaLn { + preImage + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + constructor(private readonly config: BlinkConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + this.baseUrl = config.baseUrl ?? 'https://api.blink.sv/graphql'; + } + + private headers(extra?: HeadersInit): HeadersInit { + return { + 'x-api-key': this.config.apiKey, + 'content-type': 'application/json', + ...(extra ?? {}), + }; + } + + private async gql(query: string, variables?: Record): Promise { + const payload = await requestJson>(this.fetchFn, this.baseUrl, { + method: 'POST', + headers: this.headers(), + json: { + query, + variables, + }, + timeoutMs: this.timeoutMs, + }); + + if (payload.errors?.length) { + throw new LniError('Api', payload.errors.map((error) => error.message).join(', ')); + } + + if (!payload.data) { + throw new LniError('Json', 'No data in Blink GraphQL response.'); + } + + return payload.data; + } + + private async getBtcWallet(): Promise { + const response = await this.gql(BlinkNode.ME_QUERY); + const wallet = response.me.defaultAccount.wallets.find((item) => item.walletCurrency === 'BTC'); + + if (!wallet) { + throw new LniError('Api', 'No BTC wallet found in Blink account.'); + } + + this.cachedWalletId = wallet.id; + return wallet; + } + + private async getBtcWalletId(): Promise { + if (this.cachedWalletId) { + return this.cachedWalletId; + } + + const wallet = await this.getBtcWallet(); + this.cachedWalletId = wallet.id; + return wallet.id; + } + + async getInfo(): Promise { + const wallet = await this.getBtcWallet(); + const sats = wallet.balance; + + return emptyNodeInfo({ + alias: 'Blink Node', + network: 'mainnet', + sendBalanceMsat: satsToMsats(sats), + receiveBalanceMsat: satsToMsats(sats), + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { + throw new LniError('Api', 'Bolt12 is not implemented for BlinkNode.'); + } + + const walletId = await this.getBtcWalletId(); + + const query = ` + mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { + lnInvoiceCreate(input: $input) { + invoice { + paymentRequest + paymentHash + satoshis + } + errors { + message + } + } + } + `; + + const response = await this.gql(query, { + input: { + amount: Math.floor((params.amountMsats ?? 0) / 1000), + walletId, + memo: params.description, + }, + }); + + if (response.lnInvoiceCreate.errors?.length) { + throw new LniError('Api', response.lnInvoiceCreate.errors.map((error) => error.message).join(', ')); + } + + const invoice = response.lnInvoiceCreate.invoice; + if (!invoice) { + throw new LniError('Json', 'No invoice returned from Blink invoice creation.'); + } + + return emptyTransaction({ + type: 'incoming', + invoice: invoice.paymentRequest, + paymentHash: invoice.paymentHash, + amountMsats: satsToMsats(invoice.satoshis), + createdAt: Math.floor(Date.now() / 1000), + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + const walletId = await this.getBtcWalletId(); + + const feeProbe = await this.gql( + ` + mutation lnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) { + lnInvoiceFeeProbe(input: $input) { + errors { + message + } + amount + } + } + `, + { + input: { + paymentRequest: params.invoice, + walletId, + }, + }, + ); + + if (feeProbe.lnInvoiceFeeProbe.errors?.length) { + throw new LniError('Api', feeProbe.lnInvoiceFeeProbe.errors.map((error) => error.message).join(', ')); + } + + const payment = await this.gql( + ` + mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { + status + errors { + message + } + } + } + `, + { + input: { + paymentRequest: params.invoice, + walletId, + }, + }, + ); + + if (payment.lnInvoicePaymentSend.errors?.length) { + throw new LniError('Api', payment.lnInvoicePaymentSend.errors.map((error) => error.message).join(', ')); + } + + if (payment.lnInvoicePaymentSend.status !== 'SUCCESS') { + throw new LniError('Api', `Blink payment failed with status ${payment.lnInvoicePaymentSend.status}`); + } + + return { + paymentHash: '', + preimage: '', + feeMsats: satsToMsats(feeProbe.lnInvoiceFeeProbe.amount ?? 0), + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for BlinkNode.'); + } + + async getOffer(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for BlinkNode.'); + } + + async listOffers(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for BlinkNode.'); + } + + async payOffer(_offer: string, _amountMsats: number, _payerNote?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for BlinkNode.'); + } + + private mapTransaction(node: BlinkTransactionNode): Transaction { + const paymentHash = + node.initiationVia?.__typename === 'InitiationViaLn' + ? (node.initiationVia.paymentHash ?? '') + : ''; + const preimage = + node.settlementVia?.__typename === 'SettlementViaLn' ? (node.settlementVia.preImage ?? '') : ''; + + const amountMsats = node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementAmount ?? 0)) : 0; + const feeMsats = node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementFee ?? 0)) : 0; + + return emptyTransaction({ + type: node.direction === 'SEND' ? 'outgoing' : 'incoming', + paymentHash, + preimage, + amountMsats, + feesPaid: feeMsats, + createdAt: node.createdAt, + settledAt: node.status === 'SUCCESS' ? node.createdAt : 0, + description: node.memo ?? '', + descriptionHash: '', + payerNote: '', + externalId: node.id, + }); + } + + private async listTransactionsPage(args: { + first: number; + after?: string | null; + paymentHash?: string; + search?: string; + }): Promise { + const response: BlinkTransactionsQuery = await this.gql(BlinkNode.TRANSACTIONS_QUERY, { + first: Math.max(args.first, 1), + after: args.after ?? null, + }); + + const page: BlinkTransactionsQuery['me']['defaultAccount']['transactions'] = + response.me.defaultAccount.transactions; + const edges = page.edges; + const transactions = edges + .map(({ node }) => this.mapTransaction(node)) + .filter((tx) => { + if (args.paymentHash && tx.paymentHash !== args.paymentHash) { + return false; + } + return matchesSearch(tx, args.search); + }); + + if (!page.pageInfo.hasNextPage) { + return { + transactions, + nextCursor: null, + }; + } + + const nextCursor: string | null = page.pageInfo.endCursor ?? edges[edges.length - 1]?.cursor ?? null; + return { + transactions, + nextCursor: nextCursor && nextCursor !== args.after ? nextCursor : null, + }; + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + if (!params.paymentHash) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash for BlinkNode.'); + } + + let after: string | null = null; + + while (true) { + const page = await this.listTransactionsPage({ + first: 100, + after, + paymentHash: params.paymentHash, + search: params.search, + }); + + const match = page.transactions.find((tx) => tx.paymentHash === params.paymentHash); + if (match) { + return match; + } + + if (!page.nextCursor) { + break; + } + + after = page.nextCursor; + } + + throw new LniError('Api', `Transaction not found for payment hash: ${params.paymentHash}`); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const limit = + params.limit > 0 + ? params.limit + : Math.min(BlinkNode.MAX_TRANSACTION_FETCH, BlinkNode.DEFAULT_PAGE_SIZE * 10); + const from = Math.max(params.from, 0); + const pageSize = Math.max(Math.min(limit, BlinkNode.DEFAULT_PAGE_SIZE), 1); + + let after: string | null = null; + let skipped = 0; + const transactions: Transaction[] = []; + + while (transactions.length < limit) { + const page = await this.listTransactionsPage({ + first: pageSize, + after, + paymentHash: params.paymentHash, + search: params.search, + }); + if (!page.transactions.length && !page.nextCursor) { + break; + } + + for (const tx of page.transactions) { + if (skipped < from) { + skipped += 1; + continue; + } + + transactions.push(tx); + if (transactions.length >= limit) { + break; + } + } + + if (!page.nextCursor) { + break; + } + + after = page.nextCursor; + } + + return transactions; + } + + async decode(str: string): Promise { + return str; + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), + }); + } +} diff --git a/bindings/typescript/src/nodes/cln.ts b/bindings/typescript/src/nodes/cln.ts new file mode 100644 index 0000000..381777d --- /dev/null +++ b/bindings/typescript/src/nodes/cln.ts @@ -0,0 +1,406 @@ +import { LniError } from '../errors.js'; +import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, parseOptionalNumber } from '../internal/transform.js'; +import { InvoiceType, type ClnConfig, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Transaction } from '../types.js'; + +interface ClnInfoResponse { + id: string; + alias: string; + color: string; + network: string; + blockheight: number; +} + +interface ClnListFundsResponse { + channels: Array<{ + connected: boolean; + state: string; + our_amount_msat: number; + amount_msat: number; + }>; +} + +interface ClnBolt11Response { + payment_hash: string; + bolt11: string; +} + +interface ClnPayResponse { + payment_hash: string; + payment_preimage: string; + amount_msat: number; + amount_sent_msat: number; +} + +interface ClnFetchInvoiceResponse { + invoice: string; +} + +interface ClnInvoice { + label: string; + bolt11?: string; + bolt12?: string; + payment_hash: string; + amount_received_msat?: number; + payment_preimage?: string; + description?: string; + expires_at: number; + expiry?: number; + expiry_seconds?: number; + paid_at?: number; + amount_msat?: number; + invreq_payer_note?: string; +} + +interface ClnInvoicesResponse { + invoices: ClnInvoice[]; +} + +interface ClnOfferResponse { + offer_id?: string; + bolt12: string; + active: boolean; + single_use: boolean; + used: boolean; +} + +interface ClnListOffersResponse { + offers: Offer[]; +} + +function newInvoiceLabel(): string { + if (globalThis.crypto?.randomUUID) { + return `lni.${globalThis.crypto.randomUUID()}`; + } + + return `lni.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`; +} + +export class ClnNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + + constructor(private readonly config: ClnConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + } + + private headers(extra?: HeadersInit): HeadersInit { + return { + rune: this.config.rune, + 'content-type': 'application/json', + ...(extra ?? {}), + }; + } + + private async postJson(path: string, json: unknown = {}): Promise { + return requestJson(this.fetchFn, buildUrl(this.config.url, path), { + method: 'POST', + headers: this.headers(), + json, + timeoutMs: this.timeoutMs, + }); + } + + private async postText(path: string, json: unknown = {}): Promise { + return requestText(this.fetchFn, buildUrl(this.config.url, path), { + method: 'POST', + headers: this.headers(), + json, + timeoutMs: this.timeoutMs, + }); + } + + private async fetchInvoiceFromOffer(offer: string, amountMsats: number, payerNote?: string): Promise { + const payload = await this.postJson('/v1/fetchinvoice', { + offer, + amount_msat: amountMsats, + payer_note: payerNote, + timeout: 60, + }); + + if (!payload.invoice) { + throw new LniError('Api', 'Missing BOLT12 invoice'); + } + + return payload.invoice; + } + + private invoiceToTransaction(invoice: ClnInvoice): Transaction { + const expiresAt = parseOptionalNumber(invoice.expires_at); + const expirySeconds = parseOptionalNumber(invoice.expiry_seconds ?? invoice.expiry); + + let createdAt = 0; + if (expiresAt > 0 && expirySeconds > 0) { + createdAt = Math.max(expiresAt - expirySeconds, 0); + } + if (createdAt <= 0) { + createdAt = parseOptionalNumber(invoice.paid_at); + } + if (createdAt <= 0) { + createdAt = Math.floor(Date.now() / 1000); + } + + return emptyTransaction({ + type: 'incoming', + invoice: invoice.bolt11 ?? invoice.bolt12 ?? '', + preimage: invoice.payment_preimage ?? '', + paymentHash: invoice.payment_hash, + amountMsats: invoice.amount_received_msat ?? invoice.amount_msat ?? 0, + feesPaid: 0, + createdAt, + expiresAt, + settledAt: parseOptionalNumber(invoice.paid_at), + description: invoice.description ?? '', + descriptionHash: '', + payerNote: invoice.invreq_payer_note ?? '', + externalId: invoice.label, + }); + } + + async getInfo(): Promise { + const [info, funds] = await Promise.all([ + this.postJson('/v1/getinfo', {}), + this.postJson('/v1/listfunds', {}), + ]); + + let sendBalanceMsat = 0; + let receiveBalanceMsat = 0; + let unsettledSendBalanceMsat = 0; + let unsettledReceiveBalanceMsat = 0; + let pendingOpenSendBalance = 0; + let pendingOpenReceiveBalance = 0; + + for (const channel of funds.channels) { + const channelAmount = parseOptionalNumber(channel.amount_msat); + const localAmount = parseOptionalNumber(channel.our_amount_msat); + const remoteAmount = channelAmount - localAmount; + + if (channel.state === 'CHANNELD_NORMAL' && channel.connected) { + sendBalanceMsat += localAmount; + receiveBalanceMsat += remoteAmount; + continue; + } + + if (channel.state === 'CHANNELD_NORMAL' && !channel.connected) { + unsettledSendBalanceMsat += localAmount; + unsettledReceiveBalanceMsat += remoteAmount; + continue; + } + + if ( + channel.state === 'CHANNELD_AWAITING_LOCKIN' || + channel.state === 'DUALOPEND_AWAITING_LOCKIN' || + channel.state === 'DUALOPEND_OPEN_INIT' || + channel.state === 'DUALOPEND_OPEN_COMMITTED' || + channel.state === 'DUALOPEND_OPEN_COMMIT_READY' || + channel.state === 'OPENINGD' + ) { + pendingOpenSendBalance += localAmount; + pendingOpenReceiveBalance += remoteAmount; + } + } + + return emptyNodeInfo({ + alias: info.alias, + color: info.color, + pubkey: info.id, + network: info.network, + blockHeight: info.blockheight, + sendBalanceMsat, + receiveBalanceMsat, + unsettledSendBalanceMsat, + unsettledReceiveBalanceMsat, + pendingOpenSendBalance, + pendingOpenReceiveBalance, + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + const invoiceType = params.invoiceType ?? InvoiceType.Bolt11; + const now = Math.floor(Date.now() / 1000); + const expirySeconds = Math.max(params.expiry ?? 3600, 0); + const expiresAt = now + expirySeconds; + + if (invoiceType === InvoiceType.Bolt12) { + if (!params.offer) { + throw new LniError('InvalidInput', 'Offer is required for BOLT12 invoice creation with CLN.'); + } + + const invoice = await this.fetchInvoiceFromOffer( + params.offer, + params.amountMsats ?? 0, + params.description, + ); + + return emptyTransaction({ + type: 'incoming', + invoice, + amountMsats: params.amountMsats ?? 0, + expiresAt, + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + const payload = await this.postJson('/v1/invoice', { + description: params.description ?? '', + amount_msat: params.amountMsats !== undefined ? String(params.amountMsats) : 'any', + expiry: params.expiry, + label: newInvoiceLabel(), + }); + + return emptyTransaction({ + type: 'incoming', + invoice: payload.bolt11, + paymentHash: payload.payment_hash, + amountMsats: params.amountMsats ?? 0, + expiresAt, + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + if (params.feeLimitMsat !== undefined && params.feeLimitPercentage !== undefined) { + throw new LniError('InvalidInput', 'Cannot set both feeLimitMsat and feeLimitPercentage.'); + } + + const body: Record = { + bolt11: params.invoice, + }; + + if (params.amountMsats !== undefined) { + body.amount_msat = String(params.amountMsats); + } + if (params.feeLimitMsat !== undefined) { + body.maxfee = String(params.feeLimitMsat); + } + if (params.feeLimitPercentage !== undefined) { + body.maxfeepercent = params.feeLimitPercentage; + } + if (params.timeoutSeconds !== undefined) { + body.retry_for = String(params.timeoutSeconds); + } + + const payload = await this.postJson('/v1/pay', body); + + return { + paymentHash: payload.payment_hash, + preimage: payload.payment_preimage, + feeMsats: parseOptionalNumber(payload.amount_sent_msat) - parseOptionalNumber(payload.amount_msat), + }; + } + + async createOffer(params: CreateOfferParams): Promise { + const payload = await this.postJson('/v1/offer', { + amount: params.amountMsats !== undefined ? `${params.amountMsats}msat` : 'any', + description: params.description, + }); + + return { + offerId: payload.offer_id ?? '', + bolt12: payload.bolt12, + label: params.description, + active: payload.active, + singleUse: payload.single_use, + used: payload.used, + amountMsats: params.amountMsats, + }; + } + + async getOffer(search?: string): Promise { + const offers = await this.listOffers(search); + if (!offers.length) { + throw new LniError('Api', search ? `Offer not found for search: ${search}` : 'Offer not found'); + } + + return offers[0]!; + } + + async listOffers(search?: string): Promise { + const payload = await this.postJson('/v1/listoffers', { + ...(search ? { offer_id: search } : {}), + }); + + return payload.offers; + } + + async payOffer(offer: string, amountMsats: number, payerNote?: string): Promise { + const bolt11 = await this.fetchInvoiceFromOffer(offer, amountMsats, payerNote); + const payload = await this.postJson('/v1/pay', { + bolt11, + maxfeepercent: 1, + retry_for: 60, + }); + + return { + paymentHash: payload.payment_hash, + preimage: payload.payment_preimage, + feeMsats: parseOptionalNumber(payload.amount_sent_msat) - parseOptionalNumber(payload.amount_msat), + }; + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + const query: Record = {}; + if (params.paymentHash) { + query.payment_hash = params.paymentHash; + } else if (params.search) { + query.payment_hash = params.search; + } + + const payload = await this.postJson('/v1/listinvoices', query); + + const invoice = payload.invoices[0]; + if (!invoice) { + throw new LniError('Api', 'No matching invoice found'); + } + + return this.invoiceToTransaction(invoice); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const payload = await this.postJson('/v1/listinvoices', { + start: params.from, + index: 'created', + limit: params.limit || undefined, + payment_hash: params.paymentHash, + }); + + const transactions = payload.invoices.map((invoice) => this.invoiceToTransaction(invoice)); + + if (params.search) { + return transactions.filter((tx) => { + const normalized = params.search?.toLowerCase() ?? ''; + return ( + tx.paymentHash.toLowerCase().includes(normalized) || + tx.description.toLowerCase().includes(normalized) || + (tx.payerNote ?? '').toLowerCase().includes(normalized) + ); + }); + } + + return transactions; + } + + async decode(str: string): Promise { + return this.postText('/v1/decode', { string: str }); + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => + this.lookupInvoice({ + paymentHash: params.paymentHash, + search: params.search, + }), + }); + } +} diff --git a/bindings/typescript/src/nodes/lnd.ts b/bindings/typescript/src/nodes/lnd.ts new file mode 100644 index 0000000..7953493 --- /dev/null +++ b/bindings/typescript/src/nodes/lnd.ts @@ -0,0 +1,314 @@ +import { LniError } from '../errors.js'; +import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, parseOptionalNumber, rHashToHex } from '../internal/transform.js'; +import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type LndConfig, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type Transaction } from '../types.js'; + +interface LndGetInfoResponse { + alias: string; + color: string; + identity_pubkey: string; + block_height: number; + block_hash: string; + chains: Array<{ network: string }>; +} + +interface LndBalancesResponse { + local_balance?: { msat?: string }; + remote_balance?: { msat?: string }; + unsettled_local_balance?: { msat?: string }; + unsettled_remote_balance?: { msat?: string }; + pending_open_local_balance?: { msat?: string }; + pending_open_remote_balance?: { msat?: string }; +} + +interface LndCreateInvoiceResponse { + r_hash: string; + payment_request: string; +} + +interface LndInvoiceResponse { + memo?: string; + r_preimage?: string; + r_hash?: string; + value_msat?: string; + creation_date?: string; + settle_date?: string; + payment_request?: string; + description_hash?: string; + expiry?: string; + amt_paid_msat?: string; +} + +interface LndInvoiceListResponse { + invoices: LndInvoiceResponse[]; +} + +interface LndPayResult { + payment_hash: string; + payment_preimage: string; + fee_msat: string; + status: string; + failure_reason?: string; +} + +interface LndPayResponseWrapper { + result?: LndPayResult; + error?: { + message?: string; + }; +} + +export class LndNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + + constructor(private readonly config: LndConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + } + + private headers(extra?: HeadersInit): HeadersInit { + return { + 'grpc-metadata-macaroon': this.config.macaroon, + ...(extra ?? {}), + }; + } + + private async getJson(path: string): Promise { + return requestJson(this.fetchFn, buildUrl(this.config.url, path), { + method: 'GET', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + + private async postJson(path: string, json: unknown): Promise { + return requestJson(this.fetchFn, buildUrl(this.config.url, path), { + method: 'POST', + headers: this.headers({ 'content-type': 'application/json' }), + json, + timeoutMs: this.timeoutMs, + }); + } + + private isPermissionDenied(error: unknown): boolean { + if (!(error instanceof LniError)) { + return false; + } + + if (error.code !== 'Http') { + return false; + } + + const details = `${error.message} ${error.body ?? ''}`.toLowerCase(); + return details.includes('permission denied'); + } + + private mapInvoice(invoice: LndInvoiceResponse): Transaction { + return emptyTransaction({ + type: 'incoming', + invoice: invoice.payment_request ?? '', + preimage: rHashToHex(invoice.r_preimage ?? ''), + paymentHash: rHashToHex(invoice.r_hash ?? ''), + amountMsats: parseOptionalNumber(invoice.amt_paid_msat), + feesPaid: parseOptionalNumber(invoice.value_msat), + createdAt: parseOptionalNumber(invoice.creation_date), + expiresAt: parseOptionalNumber(invoice.expiry), + settledAt: parseOptionalNumber(invoice.settle_date), + description: invoice.memo ?? '', + descriptionHash: invoice.description_hash ?? '', + payerNote: '', + externalId: '', + }); + } + + async getInfo(): Promise { + const info = await this.getJson('/v1/getinfo'); + + let balances: LndBalancesResponse = {}; + try { + balances = await this.getJson('/v1/balance/channels'); + } catch (error) { + if (!this.isPermissionDenied(error)) { + throw error; + } + } + + return emptyNodeInfo({ + alias: info.alias, + color: info.color, + pubkey: info.identity_pubkey, + network: info.chains[0]?.network ?? '', + blockHeight: info.block_height, + blockHash: info.block_hash, + sendBalanceMsat: parseOptionalNumber(balances.local_balance?.msat), + receiveBalanceMsat: parseOptionalNumber(balances.remote_balance?.msat), + unsettledSendBalanceMsat: parseOptionalNumber(balances.unsettled_local_balance?.msat), + unsettledReceiveBalanceMsat: parseOptionalNumber(balances.unsettled_remote_balance?.msat), + pendingOpenSendBalance: parseOptionalNumber(balances.pending_open_local_balance?.msat), + pendingOpenReceiveBalance: parseOptionalNumber(balances.pending_open_remote_balance?.msat), + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { + throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); + } + + const payload = await this.postJson('/v1/invoices', { + value_msat: params.amountMsats ?? 0, + memo: params.description ?? '', + expiry: params.expiry ?? 86400, + private: params.isPrivate ?? false, + ...(params.rPreimage ? { r_preimage: params.rPreimage } : {}), + ...(params.isBlinded ? { is_blinded: true } : {}), + }); + + return emptyTransaction({ + type: 'incoming', + invoice: payload.payment_request, + paymentHash: rHashToHex(payload.r_hash), + amountMsats: params.amountMsats ?? 0, + expiresAt: params.expiry ?? 86400, + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + const body: Record = { + payment_request: params.invoice, + allow_self_payment: params.allowSelfPayment ?? false, + timeout_seconds: params.timeoutSeconds ?? 60, + }; + + if (params.feeLimitPercentage !== undefined && params.amountMsats !== undefined) { + body.fee_limit = { + fixed_msat: String(params.amountMsats), + percent: params.feeLimitPercentage, + }; + } + + const responseText = await requestText(this.fetchFn, buildUrl(this.config.url, '/v2/router/send'), { + method: 'POST', + headers: this.headers({ 'content-type': 'application/json' }), + json: body, + timeoutMs: this.timeoutMs, + }); + + const finalLine = responseText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + + if (!finalLine) { + throw new LniError('Json', 'Missing payment response from LND router endpoint.'); + } + + let wrapped: LndPayResponseWrapper; + try { + wrapped = JSON.parse(finalLine) as LndPayResponseWrapper; + } catch (error) { + throw new LniError('Json', `Failed to parse LND pay response: ${(error as Error).message}`); + } + + if (wrapped.error) { + throw new LniError('Api', `Payment failed: ${wrapped.error.message ?? 'unknown reason'}`); + } + + if (!wrapped.result) { + throw new LniError('Json', 'Missing result payload in LND pay response.'); + } + + if (wrapped.result.status === 'FAILED') { + throw new LniError('Api', `Payment failed: ${wrapped.result.failure_reason ?? 'unknown reason'}`); + } + + if (wrapped.result.status === 'IN_FLIGHT') { + throw new LniError('Api', 'Payment is still in-flight. Increase timeoutSeconds and retry.'); + } + + if (wrapped.result.status !== 'SUCCEEDED') { + throw new LniError('Api', `Unknown payment status: ${wrapped.result.status}`); + } + + return { + paymentHash: wrapped.result.payment_hash, + preimage: wrapped.result.payment_preimage, + feeMsats: parseOptionalNumber(wrapped.result.fee_msat), + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); + } + + async getOffer(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); + } + + async listOffers(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); + } + + async payOffer(_offer: string, _amountMsats: number, _payerNote?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for LndNode.'); + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + if (!params.paymentHash) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash for LndNode.'); + } + + const payload = await this.getJson(`/v1/invoice/${params.paymentHash}`); + return this.mapInvoice(payload); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const payload = await this.getJson('/v1/invoices'); + const sorted = payload.invoices + .map((invoice) => this.mapInvoice(invoice)) + .sort((a, b) => b.createdAt - a.createdAt); + + const filtered = sorted.filter((tx) => { + if (params.paymentHash && tx.paymentHash !== params.paymentHash) { + return false; + } + + if (!params.search) { + return true; + } + + const search = params.search.toLowerCase(); + return ( + tx.paymentHash.toLowerCase().includes(search) || + tx.description.toLowerCase().includes(search) || + tx.invoice.toLowerCase().includes(search) + ); + }); + + const from = Math.max(params.from, 0); + const end = params.limit > 0 ? from + params.limit : undefined; + return filtered.slice(from, end); + } + + async decode(str: string): Promise { + return requestText(this.fetchFn, buildUrl(this.config.url, `/v1/payreq/${encodeURIComponent(str)}`), { + method: 'GET', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), + }); + } +} diff --git a/bindings/typescript/src/nodes/nwc.ts b/bindings/typescript/src/nodes/nwc.ts new file mode 100644 index 0000000..c2dfc85 --- /dev/null +++ b/bindings/typescript/src/nodes/nwc.ts @@ -0,0 +1,223 @@ +import { NWCClient, type Nip47GetBalanceResponse, type Nip47GetInfoResponse, type Nip47ListTransactionsResponse, type Nip47Transaction } from '@getalby/sdk/nwc'; +import { LniError } from '../errors.js'; +import { bytesToHex, hexToBytes } from '../internal/encoding.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, matchesSearch, parseOptionalNumber } from '../internal/transform.js'; +import type { CreateInvoiceParams, CreateOfferParams, InvoiceEventCallback, LightningNode, ListTransactionsParams, LookupInvoiceParams, NodeInfo, NodeRequestOptions, NwcConfig, Offer, OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Transaction } from '../types.js'; + +function extractPubkeyFromNwcUri(uri: string): string { + try { + const parsed = NWCClient.parseWalletConnectUrl(uri); + return parsed.walletPubkey ?? ''; + } catch { + // ignore + } + + const withoutParams = uri.split('?')[0] ?? ''; + if (withoutParams.startsWith('nostr+walletconnect://')) { + return withoutParams.replace('nostr+walletconnect://', ''); + } + + return ''; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + if (!globalThis.crypto?.subtle) { + throw new LniError('Api', 'Web Crypto API is required to hash NWC preimages.'); + } + + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as BufferSource); + return bytesToHex(new Uint8Array(digest)); +} + +function nwcTransactionToLniTransaction(tx: Nip47Transaction): Transaction { + return emptyTransaction({ + type: tx.type === 'outgoing' ? 'outgoing' : 'incoming', + invoice: tx.invoice ?? '', + description: tx.description ?? '', + descriptionHash: tx.description_hash ?? '', + preimage: tx.preimage ?? '', + paymentHash: tx.payment_hash ?? '', + amountMsats: parseOptionalNumber(tx.amount), + feesPaid: parseOptionalNumber(tx.fees_paid), + createdAt: parseOptionalNumber(tx.created_at), + expiresAt: parseOptionalNumber(tx.expires_at), + settledAt: parseOptionalNumber(tx.settled_at), + payerNote: '', + externalId: '', + }); +} + +export class NwcNode implements LightningNode { + private readonly client: NWCClient; + + constructor(private readonly config: NwcConfig, _options: NodeRequestOptions = {}) { + this.client = new NWCClient({ + nostrWalletConnectUrl: config.nwcUri, + }); + } + + close(): void { + this.client.close(); + } + + async getInfo(): Promise { + const balance = await this.client.getBalance().catch((error) => { + throw new LniError('Api', `Failed to get balance: ${(error as Error)?.message ?? 'unknown error'}`); + }); + + const pubkeyFallback = extractPubkeyFromNwcUri(this.config.nwcUri); + + try { + const info = await this.client.getInfo(); + return this.mapInfoWithBalance(info, balance, pubkeyFallback); + } catch { + return emptyNodeInfo({ + alias: 'NWC Node', + pubkey: pubkeyFallback, + network: 'mainnet', + sendBalanceMsat: parseOptionalNumber(balance.balance), + }); + } + } + + private mapInfoWithBalance( + info: Nip47GetInfoResponse, + balance: Nip47GetBalanceResponse, + pubkeyFallback: string, + ): NodeInfo { + return emptyNodeInfo({ + alias: info.alias ?? 'NWC Node', + color: info.color ?? '', + pubkey: info.pubkey ?? pubkeyFallback, + network: info.network ?? 'mainnet', + blockHeight: parseOptionalNumber(info.block_height), + blockHash: info.block_hash ?? '', + sendBalanceMsat: parseOptionalNumber(balance.balance), + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + const tx = await this.client + .makeInvoice({ + amount: params.amountMsats ?? 0, + description: params.description, + description_hash: params.descriptionHash, + expiry: params.expiry, + }) + .catch((error) => { + throw new LniError('Api', `Failed to create invoice: ${(error as Error)?.message ?? 'unknown error'}`); + }); + + return nwcTransactionToLniTransaction(tx); + } + + async payInvoice(params: PayInvoiceParams): Promise { + const response = await this.client + .payInvoice({ + invoice: params.invoice, + amount: params.amountMsats, + }) + .catch((error) => { + throw new LniError('Api', `Failed to pay invoice: ${(error as Error)?.message ?? 'unknown error'}`); + }); + + let paymentHash = ''; + if (response.preimage) { + let preimageBytes: Uint8Array; + try { + preimageBytes = hexToBytes(response.preimage); + } catch (error) { + throw new LniError('InvalidInput', `Invalid preimage hex: ${(error as Error).message}`); + } + + paymentHash = await sha256Hex(preimageBytes); + } + + return { + paymentHash, + preimage: response.preimage, + feeMsats: parseOptionalNumber(response.fees_paid), + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw new LniError('Api', 'NWC does not support offers (BOLT12) yet.'); + } + + async getOffer(_search?: string): Promise { + throw new LniError('Api', 'NWC does not support offers (BOLT12) yet.'); + } + + async listOffers(_search?: string): Promise { + throw new LniError('Api', 'NWC does not support offers (BOLT12) yet.'); + } + + async payOffer(_offer: string, _amountMsats: number, _payerNote?: string): Promise { + throw new LniError('Api', 'NWC does not support offers (BOLT12) yet.'); + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + const paymentHash = params.paymentHash; + const invoice = params.search; + + if (!paymentHash && !invoice) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash or search (invoice) for NwcNode.'); + } + + const tx = await this.client + .lookupInvoice({ + payment_hash: paymentHash, + invoice, + }) + .catch((error) => { + throw new LniError('Api', `Failed to lookup invoice: ${(error as Error)?.message ?? 'unknown error'}`); + }); + + return nwcTransactionToLniTransaction(tx); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const response = await this.client + .listTransactions({ + from: params.from > 0 ? params.from : undefined, + limit: params.limit > 0 ? params.limit : undefined, + }) + .catch((error) => { + throw new LniError('Api', `Failed to list transactions: ${(error as Error)?.message ?? 'unknown error'}`); + }); + + return this.filterTransactions(response, params); + } + + private filterTransactions( + response: Nip47ListTransactionsResponse, + params: ListTransactionsParams, + ): Transaction[] { + const mapped = response.transactions.map((tx) => nwcTransactionToLniTransaction(tx)); + + return mapped.filter((tx) => { + if (params.paymentHash && tx.paymentHash !== params.paymentHash) { + return false; + } + + return matchesSearch(tx, params.search); + }); + } + + async decode(str: string): Promise { + return str; + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => + this.lookupInvoice({ + paymentHash: params.paymentHash, + search: params.search, + }), + }); + } +} diff --git a/bindings/typescript/src/nodes/phoenixd.ts b/bindings/typescript/src/nodes/phoenixd.ts new file mode 100644 index 0000000..c7e1711 --- /dev/null +++ b/bindings/typescript/src/nodes/phoenixd.ts @@ -0,0 +1,358 @@ +import { LniError } from '../errors.js'; +import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, matchesSearch, satsToMsats, toUnixSeconds } from '../internal/transform.js'; +import { encodeBase64 } from '../internal/encoding.js'; +import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type PhoenixdConfig, type Transaction, type NodeInfo } from '../types.js'; + +interface PhoenixdInfoResponse { + nodeId: string; + channels: Array<{ + balanceSat: number; + inboundLiquiditySat: number; + }>; +} + +interface PhoenixdBalanceResponse { + feeCreditSat: number; +} + +interface PhoenixdBolt11Response { + serialized: string; + paymentHash: string; +} + +interface PhoenixdPayResponse { + paymentHash: string; + paymentPreimage: string; + routingFeeSat: number; +} + +interface PhoenixdInvoiceResponse { + preimage: string; + paymentHash: string; + receivedSat: number; + fees: number; + completedAt?: number; + createdAt: number; + isPaid: boolean; + invoice?: string; + description?: string; + payerNote?: string; + externalId?: string; +} + +interface PhoenixdOutgoingPaymentResponse { + paymentId?: string; + preimage?: string; + paymentHash?: string; + sent: number; + fees: number; + createdAt: number; + completedAt: number; + payerNote?: string; + externalId?: string; +} + +export class PhoenixdNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + + constructor(private readonly config: PhoenixdConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + } + + private authHeader(): string { + return `Basic ${encodeBase64(`:${this.config.password}`)}`; + } + + private async requestJson(path: string, args: Parameters>[2]): Promise { + return requestJson(this.fetchFn, buildUrl(this.config.url, path), { + ...args, + timeoutMs: args?.timeoutMs ?? this.timeoutMs, + headers: { + authorization: this.authHeader(), + ...(args?.headers ?? {}), + }, + }); + } + + private async requestText(path: string, args: Parameters[2]): Promise { + return requestText(this.fetchFn, buildUrl(this.config.url, path), { + ...args, + timeoutMs: args?.timeoutMs ?? this.timeoutMs, + headers: { + authorization: this.authHeader(), + ...(args?.headers ?? {}), + }, + }); + } + + async getInfo(): Promise { + const [info, balance] = await Promise.all([ + this.requestJson('/getinfo', { method: 'GET' }), + this.requestJson('/getbalance', { method: 'GET' }), + ]); + + const firstChannel = info.channels[0]; + + return emptyNodeInfo({ + alias: 'Phoenixd', + pubkey: info.nodeId, + network: 'bitcoin', + sendBalanceMsat: satsToMsats(firstChannel?.balanceSat ?? 0), + receiveBalanceMsat: satsToMsats(firstChannel?.inboundLiquiditySat ?? 0), + feeCreditBalanceMsat: satsToMsats(balance.feeCreditSat), + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + const invoiceType = params.invoiceType ?? InvoiceType.Bolt11; + + if (invoiceType === InvoiceType.Bolt12) { + const offer = await this.requestText('/createoffer', { + method: 'POST', + form: { + description: params.description, + amountSat: params.amountMsats ? Math.floor(params.amountMsats / 1000) : undefined, + }, + }); + + return emptyTransaction({ + type: 'incoming', + invoice: offer.trim(), + amountMsats: params.amountMsats ?? 0, + expiresAt: params.expiry ?? 3600, + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + const payload = await this.requestJson('/createinvoice', { + method: 'POST', + form: { + amountSat: params.amountMsats ? Math.floor(params.amountMsats / 1000) : 0, + expirySeconds: params.expiry ?? 3600, + description: params.description, + }, + }); + + return emptyTransaction({ + type: 'incoming', + invoice: payload.serialized, + paymentHash: payload.paymentHash, + amountMsats: params.amountMsats ?? 0, + expiresAt: params.expiry ?? 3600, + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + const payload = await this.requestJson('/payinvoice', { + method: 'POST', + form: { + invoice: params.invoice, + amountSat: params.amountMsats ? Math.floor(params.amountMsats / 1000) : undefined, + }, + }); + + return { + paymentHash: payload.paymentHash, + preimage: payload.paymentPreimage, + feeMsats: satsToMsats(payload.routingFeeSat), + }; + } + + async createOffer(params: CreateOfferParams): Promise { + const bolt12 = await this.requestText('/createoffer', { + method: 'POST', + form: { + description: params.description, + amountSat: params.amountMsats ? Math.floor(params.amountMsats / 1000) : undefined, + }, + }); + + return { + offerId: '', + bolt12: bolt12.trim(), + label: params.description, + active: true, + singleUse: false, + used: false, + amountMsats: params.amountMsats, + }; + } + + async getOffer(): Promise { + const bolt12 = await this.requestText('/getoffer', { method: 'GET' }); + return { + offerId: '', + bolt12: bolt12.trim(), + }; + } + + async listOffers(): Promise { + return []; + } + + async payOffer(offer: string, amountMsats: number, payerNote?: string): Promise { + const payload = await this.requestJson('/payoffer', { + method: 'POST', + form: { + offer, + amountSat: Math.floor(amountMsats / 1000), + message: payerNote, + }, + }); + + return { + paymentHash: payload.paymentHash, + preimage: payload.paymentPreimage, + feeMsats: satsToMsats(payload.routingFeeSat), + }; + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + if (!params.paymentHash) { + if (!params.search) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash or search for PhoenixdNode.'); + } + + const txs = await this.listTransactions({ from: 0, limit: 100, search: params.search }); + const tx = txs[0]; + if (!tx) { + throw new LniError('Api', 'No matching transactions'); + } + return tx; + } + + const invoice = await this.requestJson(`/payments/incoming/${params.paymentHash}`, { + method: 'GET', + }); + + const settledAt = invoice.completedAt && invoice.isPaid ? toUnixSeconds(invoice.completedAt) : 0; + + return emptyTransaction({ + type: 'incoming', + invoice: invoice.invoice ?? '', + preimage: invoice.preimage, + paymentHash: invoice.paymentHash, + amountMsats: satsToMsats(invoice.receivedSat), + feesPaid: satsToMsats(invoice.fees), + createdAt: toUnixSeconds(invoice.createdAt), + expiresAt: 0, + settledAt, + description: invoice.description ?? '', + descriptionHash: '', + payerNote: invoice.payerNote ?? '', + externalId: invoice.externalId ?? '', + }); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const query = { + from: params.from ? params.from * 1000 : undefined, + limit: params.limit || undefined, + all: false, + }; + + const incoming = await requestJson( + this.fetchFn, + buildUrl(this.config.url, '/payments/incoming', query), + { + method: 'GET', + timeoutMs: this.timeoutMs, + headers: { authorization: this.authHeader() }, + }, + ); + + const outgoing = await requestJson( + this.fetchFn, + buildUrl(this.config.url, '/payments/outgoing', query), + { + method: 'GET', + timeoutMs: this.timeoutMs, + headers: { authorization: this.authHeader() }, + }, + ); + + const txs: Transaction[] = []; + + for (const item of incoming) { + const tx = emptyTransaction({ + type: 'incoming', + preimage: item.preimage, + paymentHash: item.paymentHash, + amountMsats: satsToMsats(item.receivedSat), + feesPaid: satsToMsats(item.fees), + createdAt: toUnixSeconds(item.createdAt), + settledAt: item.isPaid && item.completedAt ? toUnixSeconds(item.completedAt) : 0, + payerNote: item.payerNote ?? '', + externalId: item.externalId ?? '', + }); + + if (params.paymentHash && tx.paymentHash !== params.paymentHash) { + continue; + } + if (!matchesSearch(tx, params.search)) { + continue; + } + txs.push(tx); + } + + for (const item of outgoing) { + const tx = emptyTransaction({ + type: 'outgoing', + preimage: item.preimage ?? '', + paymentHash: item.paymentHash ?? '', + amountMsats: satsToMsats(item.sent), + feesPaid: satsToMsats(item.fees), + createdAt: toUnixSeconds(item.createdAt), + settledAt: item.completedAt ? toUnixSeconds(item.completedAt) : 0, + payerNote: item.payerNote ?? '', + externalId: item.externalId ?? item.paymentId ?? '', + }); + + if (params.paymentHash && tx.paymentHash !== params.paymentHash) { + continue; + } + if (!matchesSearch(tx, params.search)) { + continue; + } + txs.push(tx); + } + + txs.sort((a, b) => b.createdAt - a.createdAt); + return txs; + } + + async decode(str: string): Promise { + return str; + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => { + if (params.paymentHash || params.search) { + return this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }); + } + + return this.listTransactions({ from: 0, limit: 100 }).then((txs) => { + const tx = txs[0]; + if (!tx) { + throw new LniError('Api', 'No matching transactions'); + } + return tx; + }); + }, + }); + } +} diff --git a/bindings/typescript/src/nodes/speed.ts b/bindings/typescript/src/nodes/speed.ts new file mode 100644 index 0000000..c235fa1 --- /dev/null +++ b/bindings/typescript/src/nodes/speed.ts @@ -0,0 +1,241 @@ +import { LniError } from '../errors.js'; +import { buildUrl, requestJson, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { emptyNodeInfo, emptyTransaction, satsToMsats } from '../internal/transform.js'; +import { encodeBase64 } from '../internal/encoding.js'; +import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type SpeedConfig, type Transaction } from '../types.js'; + +interface SpeedBalanceResponse { + available: Array<{ + amount: number; + target_currency: string; + }>; +} + +interface SpeedCreatePaymentResponse { + id: string; + amount: number; + created: number; + modified?: number; + statement_descriptor?: string; + target_amount_paid_at?: number; + speed_fee?: { amount?: number }; + payment_method_options?: { + lightning?: { + payment_request?: string; + payment_hash?: string; + }; + }; +} + +interface SpeedSendResponse { + id: string; + status: string; + target_amount: number; + withdraw_method: string; + withdraw_request: string; + note?: string; + created: number; + modified?: number; + speed_fee: { amount: number }; +} + +interface SpeedSendFilterResponse { + data: SpeedSendResponse[]; +} + +export class SpeedNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + private readonly baseUrl: string; + + constructor(private readonly config: SpeedConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + this.baseUrl = config.baseUrl ?? 'https://api.tryspeed.com'; + } + + private headers(extra?: HeadersInit): HeadersInit { + const auth = encodeBase64(`${this.config.apiKey}:`); + return { + authorization: `Basic ${auth}`, + 'content-type': 'application/json', + ...(extra ?? {}), + }; + } + + private async getJson(path: string, query?: Record): Promise { + return requestJson(this.fetchFn, buildUrl(this.baseUrl, path, query), { + method: 'GET', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + + private async postJson(path: string, json?: unknown): Promise { + return requestJson(this.fetchFn, buildUrl(this.baseUrl, path), { + method: 'POST', + headers: this.headers(), + json, + timeoutMs: this.timeoutMs, + }); + } + + private async fetchSendTransactions(status?: string[], withdrawRequest?: string): Promise { + const payload = await this.postJson('/send/filter', { + status, + withdraw_request: withdrawRequest, + }); + + return payload.data; + } + + private sendToTransaction(send: SpeedSendResponse): Transaction { + return emptyTransaction({ + type: send.withdraw_method === 'lightning' ? 'outgoing' : 'outgoing', + invoice: send.withdraw_request, + paymentHash: '', + amountMsats: satsToMsats(send.target_amount), + feesPaid: satsToMsats(send.speed_fee.amount), + createdAt: send.created, + settledAt: send.status === 'paid' ? send.modified ?? send.created : 0, + description: send.note ?? '', + descriptionHash: '', + payerNote: send.note, + externalId: send.id, + }); + } + + async getInfo(): Promise { + const payload = await this.getJson('/balances'); + const sats = payload.available.find((item) => item.target_currency === 'SATS'); + + return emptyNodeInfo({ + alias: 'Speed Node', + network: 'mainnet', + sendBalanceMsat: sats ? satsToMsats(sats.amount) : 0, + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { + throw new LniError('Api', 'Bolt12 is not implemented for SpeedNode.'); + } + + const payload = await this.postJson('/payments', { + amount: (params.amountMsats ?? 0) / 1000, + currency: 'SATS', + memo: params.description, + external_id: null, + }); + + const lightning = payload.payment_method_options?.lightning; + + return emptyTransaction({ + type: 'incoming', + invoice: lightning?.payment_request ?? '', + paymentHash: lightning?.payment_hash ?? '', + amountMsats: satsToMsats(payload.amount), + feesPaid: satsToMsats(payload.speed_fee?.amount ?? 0), + createdAt: payload.created, + settledAt: payload.target_amount_paid_at ?? 0, + expiresAt: 0, + description: payload.statement_descriptor ?? params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: payload.id, + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + if (params.amountMsats === undefined) { + throw new LniError('InvalidInput', 'Speed payInvoice requires amountMsats for frontend mode.'); + } + + const payload = await this.postJson('/send', { + amount: params.amountMsats / 1000, + currency: 'SATS', + target_currency: 'SATS', + withdraw_method: 'lightning', + withdraw_request: params.invoice, + note: 'LNI payment', + external_id: null, + }); + + return { + paymentHash: '', + preimage: '', + feeMsats: satsToMsats(payload.speed_fee.amount), + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for SpeedNode.'); + } + + async getOffer(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for SpeedNode.'); + } + + async listOffers(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for SpeedNode.'); + } + + async payOffer(_offer: string, _amountMsats: number, _payerNote?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for SpeedNode.'); + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + const rows = await this.fetchSendTransactions( + ['paid', 'unpaid', 'failed'], + params.search, + ); + + const txs = rows.map((row) => this.sendToTransaction(row)); + + if (params.paymentHash) { + const tx = txs.find((candidate) => candidate.paymentHash === params.paymentHash); + if (!tx) { + throw new LniError('Api', `Transaction not found for payment hash: ${params.paymentHash}`); + } + return tx; + } + + if (!txs.length) { + throw new LniError('Api', 'No transactions found matching lookup parameters.'); + } + + return txs[0]!; + } + + async listTransactions(params: ListTransactionsParams): Promise { + const rows = await this.fetchSendTransactions( + params.search ? undefined : ['unpaid', 'paid', 'failed'], + params.search, + ); + + const mapped = rows.map((row) => this.sendToTransaction(row)); + const filtered = params.paymentHash + ? mapped.filter((tx) => tx.paymentHash === params.paymentHash) + : mapped; + + const start = Math.max(0, params.from || 0); + const end = params.limit > 0 ? start + params.limit : undefined; + + return filtered + .sort((a, b) => b.createdAt - a.createdAt) + .slice(start, end); + } + + async decode(str: string): Promise { + return str; + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), + }); + } +} diff --git a/bindings/typescript/src/nodes/strike.ts b/bindings/typescript/src/nodes/strike.ts new file mode 100644 index 0000000..09acd25 --- /dev/null +++ b/bindings/typescript/src/nodes/strike.ts @@ -0,0 +1,337 @@ +import { LniError } from '../errors.js'; +import { buildUrl, requestJson, requestText, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { btcToMsats, emptyNodeInfo, emptyTransaction, matchesSearch, msatsToBtc, parseOptionalNumber, toUnixSeconds } from '../internal/transform.js'; +import { InvoiceType, type CreateInvoiceParams, type CreateOfferParams, type InvoiceEventCallback, type LightningNode, type ListTransactionsParams, type LookupInvoiceParams, type NodeInfo, type NodeRequestOptions, type Offer, type OnInvoiceEventParams, type PayInvoiceParams, type PayInvoiceResponse, type StrikeConfig, type Transaction } from '../types.js'; + +interface StrikeBalance { + currency: string; + current: string; +} + +interface StrikeAmount { + amount: string; + currency: string; +} + +interface StrikeCreateReceiveResponse { + receiveRequestId: string; + created: string; + bolt11?: { + invoice: string; + paymentHash: string; + description?: string; + descriptionHash?: string; + expires: string; + }; +} + +interface StrikePaymentQuoteResponse { + paymentQuoteId: string; +} + +interface StrikePaymentExecutionResponse { + paymentId: string; +} + +interface StrikePaymentResponse { + id: string; + state: string; + created: string; + completed?: string; + description?: string; + amount: StrikeAmount; + lightning?: { + paymentHash?: string; + paymentRequest?: string; + networkFee?: StrikeAmount; + }; +} + +interface StrikeReceivesResponse { + items: Array<{ + receiveRequestId: string; + state: string; + created: string; + completed?: string; + amountReceived: StrikeAmount; + lightning?: { + invoice: string; + preimage: string; + description?: string; + descriptionHash?: string; + paymentHash: string; + }; + }>; +} + +interface StrikePaymentsResponse { + data: StrikePaymentResponse[]; +} + +export class StrikeNode implements LightningNode { + private readonly fetchFn; + private readonly timeoutMs?: number; + private readonly baseUrl: string; + + constructor(private readonly config: StrikeConfig, options: NodeRequestOptions = {}) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + this.baseUrl = config.baseUrl ?? 'https://api.strike.me/v1'; + } + + private headers(extra?: HeadersInit): HeadersInit { + return { + authorization: `Bearer ${this.config.apiKey}`, + 'content-type': 'application/json', + ...(extra ?? {}), + }; + } + + private async getJson(path: string, query?: Record): Promise { + return requestJson(this.fetchFn, buildUrl(this.baseUrl, path, query), { + method: 'GET', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + + private async postJson(path: string, json?: unknown): Promise { + return requestJson(this.fetchFn, buildUrl(this.baseUrl, path), { + method: 'POST', + headers: this.headers(), + json, + timeoutMs: this.timeoutMs, + }); + } + + private async patchJson(path: string): Promise { + return requestJson(this.fetchFn, buildUrl(this.baseUrl, path), { + method: 'PATCH', + headers: this.headers(), + timeoutMs: this.timeoutMs, + }); + } + + private isNotFoundError(error: unknown): boolean { + return error instanceof LniError && error.code === 'Http' && error.status === 404; + } + + async getInfo(): Promise { + const balances = await this.getJson('/balances'); + + const btcBalance = balances.find((balance) => balance.currency === 'BTC'); + + return emptyNodeInfo({ + alias: 'Strike Node', + network: 'mainnet', + sendBalanceMsat: btcBalance ? btcToMsats(btcBalance.current) : 0, + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { + throw new LniError('Api', 'Bolt12 is not implemented for StrikeNode.'); + } + + const response = await this.postJson('/receive-requests', { + bolt11: { + amount: + params.amountMsats !== undefined + ? { + amount: msatsToBtc(params.amountMsats), + currency: 'BTC', + } + : undefined, + description: params.description, + descriptionHash: params.descriptionHash, + expiryInSeconds: params.expiry, + }, + onchain: null, + targetCurrency: 'BTC', + }); + + const bolt11 = response.bolt11; + if (!bolt11) { + throw new LniError('Json', 'No bolt11 payload returned from Strike create invoice call.'); + } + + return emptyTransaction({ + type: 'incoming', + invoice: bolt11.invoice, + paymentHash: bolt11.paymentHash, + amountMsats: params.amountMsats ?? 0, + createdAt: toUnixSeconds(Date.parse(response.created)), + expiresAt: toUnixSeconds(Date.parse(bolt11.expires)), + description: bolt11.description ?? params.description ?? '', + descriptionHash: bolt11.descriptionHash ?? params.descriptionHash ?? '', + externalId: response.receiveRequestId, + payerNote: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + const quote = await this.postJson('/payment-quotes/lightning', { + lnInvoice: params.invoice, + sourceCurrency: 'BTC', + amount: + params.amountMsats !== undefined + ? { + amount: msatsToBtc(params.amountMsats), + currency: 'BTC', + } + : undefined, + }); + + const execution = await this.patchJson(`/payment-quotes/${quote.paymentQuoteId}/execute`); + let payment: StrikePaymentResponse; + try { + payment = await this.getJson(`/payments/${execution.paymentId}`); + } catch (error) { + throw new LniError( + 'Api', + `Strike payment executed but status lookup failed. paymentId=${execution.paymentId}`, + { cause: error }, + ); + } + + const feeMsats = payment.lightning?.networkFee ? btcToMsats(payment.lightning.networkFee.amount) : 0; + + return { + paymentHash: payment.lightning?.paymentHash ?? '', + preimage: '', + feeMsats, + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for StrikeNode.'); + } + + async getOffer(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for StrikeNode.'); + } + + async listOffers(_search?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for StrikeNode.'); + } + + async payOffer(_offer: string, _amountMsats: number, _payerNote?: string): Promise { + throw new LniError('Api', 'Bolt12 is not implemented for StrikeNode.'); + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + if (!params.paymentHash) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash for StrikeNode.'); + } + + const receives = await this.getJson('/receive-requests/receives', { + '$paymentHash': params.paymentHash, + }); + + const item = receives.items[0]; + if (!item?.lightning) { + throw new LniError('Api', `No receive found for payment hash: ${params.paymentHash}`); + } + + return emptyTransaction({ + type: 'incoming', + invoice: item.lightning.invoice, + preimage: item.lightning.preimage, + paymentHash: item.lightning.paymentHash, + amountMsats: btcToMsats(item.amountReceived.amount), + feesPaid: 0, + createdAt: toUnixSeconds(Date.parse(item.created)), + settledAt: item.state === 'COMPLETED' && item.completed ? toUnixSeconds(Date.parse(item.completed)) : 0, + description: item.lightning.description ?? item.lightning.descriptionHash ?? '', + descriptionHash: item.lightning.descriptionHash ?? '', + externalId: item.receiveRequestId, + payerNote: '', + }); + } + + async listTransactions(params: ListTransactionsParams): Promise { + const receives = await this.getJson('/receive-requests/receives', { + '$skip': params.from, + '$top': params.limit, + }); + + let outgoing: StrikePaymentsResponse = { data: [] }; + try { + outgoing = await this.getJson('/payments', { + '$skip': params.from, + '$top': params.limit, + }); + } catch (error) { + if (!this.isNotFoundError(error)) { + throw error; + } + // Strike can return 404 when there are no outgoing payments for the account. + } + + const txs: Transaction[] = []; + + for (const receive of receives.items) { + if (!receive.lightning) { + continue; + } + + const tx = emptyTransaction({ + type: 'incoming', + invoice: receive.lightning.invoice, + preimage: receive.lightning.preimage, + paymentHash: receive.lightning.paymentHash, + amountMsats: btcToMsats(receive.amountReceived.amount), + feesPaid: 0, + createdAt: toUnixSeconds(Date.parse(receive.created)), + settledAt: receive.state === 'COMPLETED' && receive.completed ? toUnixSeconds(Date.parse(receive.completed)) : 0, + description: receive.lightning.description ?? receive.lightning.descriptionHash ?? '', + descriptionHash: receive.lightning.descriptionHash ?? '', + externalId: receive.receiveRequestId, + payerNote: '', + }); + + txs.push(tx); + } + + for (const payment of outgoing.data) { + const tx = emptyTransaction({ + type: 'outgoing', + invoice: payment.lightning?.paymentRequest ?? '', + paymentHash: payment.lightning?.paymentHash ?? '', + amountMsats: btcToMsats(payment.amount.amount), + feesPaid: payment.lightning?.networkFee ? btcToMsats(payment.lightning.networkFee.amount) : 0, + createdAt: toUnixSeconds(Date.parse(payment.created)), + settledAt: payment.state === 'COMPLETED' && payment.completed ? toUnixSeconds(Date.parse(payment.completed)) : 0, + description: payment.description ?? '', + descriptionHash: '', + externalId: payment.id, + payerNote: '', + }); + + txs.push(tx); + } + + const filtered = txs.filter((tx) => { + if (params.paymentHash && tx.paymentHash !== params.paymentHash) { + return false; + } + return matchesSearch(tx, params.search); + }); + + const sorted = filtered.sort((a, b) => b.createdAt - a.createdAt); + return sorted.slice(0, params.limit > 0 ? params.limit : undefined); + } + + async decode(str: string): Promise { + return str; + } + + async onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + await pollInvoiceEvents({ + params, + callback, + lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), + }); + } +} diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts new file mode 100644 index 0000000..226445d --- /dev/null +++ b/bindings/typescript/src/types.ts @@ -0,0 +1,212 @@ +export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export type InvoiceEventStatus = 'success' | 'pending' | 'failure'; +export type InvoiceEventCallback = (status: InvoiceEventStatus, transaction?: Transaction) => void; + +export enum InvoiceType { + Bolt11 = 'Bolt11', + Bolt12 = 'Bolt12', +} + +export interface NodeInfo { + alias: string; + color: string; + pubkey: string; + network: string; + blockHeight: number; + blockHash: string; + sendBalanceMsat: number; + receiveBalanceMsat: number; + feeCreditBalanceMsat: number; + unsettledSendBalanceMsat: number; + unsettledReceiveBalanceMsat: number; + pendingOpenSendBalance: number; + pendingOpenReceiveBalance: number; +} + +export type TransactionType = 'incoming' | 'outgoing'; + +export interface Transaction { + type: TransactionType; + invoice: string; + description: string; + descriptionHash: string; + preimage: string; + paymentHash: string; + amountMsats: number; + feesPaid: number; + createdAt: number; + expiresAt: number; + settledAt: number; + payerNote?: string; + externalId?: string; +} + +export interface PayInvoiceResponse { + paymentHash: string; + preimage: string; + feeMsats: number; +} + +export interface Offer { + offerId: string; + bolt12: string; + label?: string; + active?: boolean; + singleUse?: boolean; + used?: boolean; + amountMsats?: number; +} + +export interface CreateInvoiceParams { + invoiceType?: InvoiceType; + amountMsats?: number; + offer?: string; + description?: string; + descriptionHash?: string; + expiry?: number; + rPreimage?: string; + isBlinded?: boolean; + isKeysend?: boolean; + isAmp?: boolean; + isPrivate?: boolean; +} + +export interface CreateOfferParams { + description?: string; + amountMsats?: number; +} + +export interface PayInvoiceParams { + invoice: string; + feeLimitMsat?: number; + feeLimitPercentage?: number; + timeoutSeconds?: number; + amountMsats?: number; + maxParts?: number; + firstHopPubkey?: string; + lastHopPubkey?: string; + allowSelfPayment?: boolean; + isAmp?: boolean; +} + +export interface LookupInvoiceParams { + paymentHash?: string; + search?: string; +} + +export interface ListTransactionsParams { + from: number; + limit: number; + // Exact payment hash match. + paymentHash?: string; + // Case-insensitive partial match across common transaction text fields. + search?: string; +} + +export interface OnInvoiceEventParams { + paymentHash?: string; + search?: string; + pollingDelaySec: number; + maxPollingSec: number; +} + +export interface NodeRequestOptions { + fetch?: FetchLike; +} + +export interface PhoenixdConfig { + url: string; + password: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface ClnConfig { + url: string; + rune: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface LndConfig { + url: string; + macaroon: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface NwcConfig { + nwcUri: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface StrikeConfig { + baseUrl?: string; + apiKey: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface SpeedConfig { + baseUrl?: string; + apiKey: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface BlinkConfig { + baseUrl?: string; + apiKey: string; + socks5Proxy?: string; + acceptInvalidCerts?: boolean; + httpTimeout?: number; +} + +export interface LightningNode { + getInfo(): Promise; + createInvoice(params: CreateInvoiceParams): Promise; + payInvoice(params: PayInvoiceParams): Promise; + createOffer(params: CreateOfferParams): Promise; + getOffer(search?: string): Promise; + listOffers(search?: string): Promise; + payOffer(offer: string, amountMsats: number, payerNote?: string): Promise; + lookupInvoice(params: LookupInvoiceParams): Promise; + listTransactions(params: ListTransactionsParams): Promise; + decode(str: string): Promise; + onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise; +} + +export type BackendNodeKind = + | 'phoenixd' + | 'cln' + | 'lnd' + | 'nwc' + | 'strike' + | 'speed' + | 'blink'; + +export type BackendNodeConfig = + | { kind: 'phoenixd'; config: PhoenixdConfig } + | { kind: 'cln'; config: ClnConfig } + | { kind: 'lnd'; config: LndConfig } + | { kind: 'nwc'; config: NwcConfig } + | { kind: 'strike'; config: StrikeConfig } + | { kind: 'speed'; config: SpeedConfig } + | { kind: 'blink'; config: BlinkConfig }; + +export interface PaymentInfo { + destinationType: 'bolt11' | 'bolt12' | 'lnurl' | 'lightning_address'; + destination: string; + amountMsats?: number; + minSendableMsats?: number; + maxSendableMsats?: number; + description?: string; +} diff --git a/bindings/typescript/tsconfig.build.json b/bindings/typescript/tsconfig.build.json new file mode 100644 index 0000000..0b7fb3b --- /dev/null +++ b/bindings/typescript/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": [ + "dist", + "node_modules", + "src/**/__tests__/**" + ] +} diff --git a/bindings/typescript/tsconfig.json b/bindings/typescript/tsconfig.json new file mode 100644 index 0000000..f159153 --- /dev/null +++ b/bindings/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "skipLibCheck": true, + "types": ["node"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/docs/THREAD_CONTEXT.md b/docs/THREAD_CONTEXT.md new file mode 100644 index 0000000..ebcbf35 --- /dev/null +++ b/docs/THREAD_CONTEXT.md @@ -0,0 +1,70 @@ +# Thread Context (Sanitized) + +## Goal +- Build and publish a frontend-capable TypeScript port of LNI without UniFFI. +- Keep Spark out initially, then prepare clean handoff context so another model can implement Spark next. + +## Current Package State +- Package: `@sunnyln/lni` +- Location: `bindings/typescript` +- Build/publish scripts are in `bindings/typescript/package.json`. +- Publish flow is ready (`prepack`, `pack:dry-run`, `publish:public`). + +## Implemented TypeScript Nodes +- `PhoenixdNode` +- `ClnNode` +- `LndNode` +- `NwcNode` +- `StrikeNode` +- `SpeedNode` +- `BlinkNode` + +Spark is intentionally not implemented yet. + +## Important Architecture Decisions +- Pure TypeScript implementation (no UniFFI bindings). +- Frontend-first runtime model using `fetch`. +- Shared node interface in `bindings/typescript/src/types.ts`. +- Shared HTTP and helpers in: + - `bindings/typescript/src/internal/http.ts` + - `bindings/typescript/src/internal/transform.ts` + - `bindings/typescript/src/internal/polling.ts` +- LNURL helpers in `bindings/typescript/src/lnurl.ts`. + +## Test Strategy +- Integration tests are real-backend style (no mocks). +- Tests are split by node: + - `bindings/typescript/src/__tests__/integration/*.real.test.ts` +- Shared test helpers: + - `bindings/typescript/src/__tests__/integration/helpers.ts` +- Packaging excludes tests (`bindings/typescript/tsconfig.build.json` excludes `src/**/__tests__/**`). + +## Security/Sanitization Notes +- No secrets are committed in TypeScript sources. +- Integration tests read env at runtime only. +- Verbose test logs that printed invoice objects were removed. +- Avoid storing: + - API keys + - macaroons/runes/passwords + - NWC URIs + - local absolute filesystem paths + - internal hostnames/IPs + +## Docs Updated +- Root README has install instructions for npm package and source fallback. +- TypeScript README has install/publish instructions and package import examples for `@sunnyln/lni`. + +## Spark Handoff: What to Build Next +- Implement `SparkNode` in `bindings/typescript/src/nodes/spark.ts`. +- Export it from `bindings/typescript/src/index.ts`. +- Add Spark integration tests: + - `bindings/typescript/src/__tests__/integration/spark.real.test.ts` +- Keep interface parity with existing nodes and Rust spark module behavior. + +## Spark Reference Source (Rust) +- `crates/lni/spark/` + - `api.rs` + - `lib.rs` + - `types.rs` + +Use Rust Spark behavior as the source of truth for endpoint mapping, request/response handling, and method semantics. diff --git a/readme.md b/readme.md index 30f1efd..ed83b1e 100644 --- a/readme.md +++ b/readme.md @@ -64,42 +64,45 @@ let cln_txns = cln_node.list_transactions(list_txn_params).await; #### Typescript ```typescript -const lndNode = new LndNode({ url, macaroon }); -const clnNode = new ClnNode({ url, rune }); +import { createNode, InvoiceType, type BackendNodeConfig } from '@sunnyln/lni'; -const lndNodeInfo = lndNode.getInfo(); -const clnNodeInfo = clnNode.getInfo(); +const backend: BackendNodeConfig = { + kind: 'lnd', + config: { + url: 'https://lnd.example.com', + macaroon: '...', + }, +}; + +const node = createNode(backend); +const nodeInfo = await node.getInfo(); const invoiceParams = { invoiceType: InvoiceType.Bolt11, amountMsats: 2000, description: "your memo", expiry: 1743355716, -}); +}; -const lndInvoice = await lndNode.createInvoice(invoiceParams); -const clnInvoice = await clnNode.createInvoice(invoiceParams); +const invoice = await node.createInvoice(invoiceParams); const payInvoiceParams = { - invoice: "{lnbc1***}", // BOLT 11 payment request + invoice: invoice.invoice, // BOLT 11 payment request feeLimitPercentage: 1, // 1% fee limit - allowSelfPayment: true, // This setting works with LND, but is simply ignored for CLN etc... -}); + allowSelfPayment: true, // Used by LND; ignored by nodes that do not support it +}; -const lndPayInvoice = await lndNode.payInvoice(payInvoiceParams); -const clnPayInvoice = await clnNode.payInvoice(payInvoiceParams); +const payInvoice = await node.payInvoice(payInvoiceParams); -const lndInvoiceStatus = await lndNode.lookupInvoice("{PAYMENT_HASH}"); -const clnInvoiceStatus = await clnNode.lookupInvoice("{PAYMENT_HASH}"); +const invoiceStatus = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); const listTxnParams = { from: 0, limit: 10, - payment_hash: None, // Optionally pass in the payment hash, or None to search all + paymentHash: undefined, // Optionally pass a payment hash to filter }; -const lndTxns = await lndNode.listTransactions(listTxnParams); -const clnTxns = await clnNode.listTransactions(listTxnParams); +const txns = await node.listTransactions(listTxnParams); ``` @@ -166,7 +169,7 @@ node.pay_invoice(PayInvoiceParams { invoice: bolt11, ..Default::default() }).awa **TypeScript (Node.js)** ```typescript -import { detectPaymentType, needsResolution, resolveToBolt11, getPaymentInfo } from 'lni_js'; +import { detectPaymentType, needsResolution, resolveToBolt11, getPaymentInfo } from '@sunnyln/lni'; // Auto-detect payment type const type = detectPaymentType('nicktee@strike.me'); // "lightning_address" @@ -281,6 +284,7 @@ lni ├── bindings │ ├── lni_nodejs │ ├── lni_react_native +│ ├── typescript ├── crates │ ├── lni │ |─── lnd @@ -292,6 +296,26 @@ lni │ |─── blink ``` +### TypeScript (frontend) +```sh +npm install @sunnyln/lni +``` + +Build from source: +```sh +cd bindings/typescript +npm install +npm run typecheck +npm run build +``` + +Install TypeScript binding from GitHub repo ([lightning-node-interface/lni](https://github.com/lightning-node-interface/lni)): +```sh +TMP_DIR=$(mktemp -d) && git clone --depth 1 https://github.com/lightning-node-interface/lni.git "$TMP_DIR/lni" && npm install "$TMP_DIR/lni/bindings/typescript" +``` + +Why this form: `bindings/typescript` is a subfolder package in a monorepo, so install is done from the cloned path. + Example ======== #### react-native