refactor: drop Lightning.Pub backend; LNbits-only path (3d)
LP usage in apps/machine is gone in this commit; packages/lightning/
is removed from the tree. atm.ts continues to see a 'lightningPub'
field but it is now a thin LightningBackend adapter (getBalance,
watchBalance, createInvoice, payInvoice) implemented over the LNbits
nostr-transport — no atm.ts surgery needed.
services/lightning.ts changes
- LightningPubClient import removed; CLINK helper imports
(createOfferSuccess / createOfferError / OfferErrorCode) removed —
the CLINK offer-request handler that produced LP invoices is gone.
- LightningConfig: trimmed LP fields (lightningPubPubkey,
lightningPubApiUrl, extensionApiUrl, adminToken). loadLightningConfig
reads only LNbits + relay + identity vars.
- initializeLightningServices: requires VITE_LNBITS_SERVER_PUBKEY,
fails fast if missing or if list_wallets returns no wallet.
CLINK client is still instantiated for kind-21003 management
commands (LP-independent), but offer-request wiring is removed.
- New LightningBackend interface defines the surface atm.ts uses;
the in-init adapter implements it over the LnbitsClient.
- ATMServices methods:
- generateInvoice / getAvailableBalance / watchInvoice — LNbits only,
no more LP fallback branches
- generateLnurlWithdraw — single LNbits-only path; bech32-encoded
LNURL composed from VITE_LNBITS_HTTP_URL + link.unique_hash
- generateNdebit / generateClinkOffer / generateNoffer /
sendOfferResponse remain as no-op stubs to satisfy the state-
machine contract
- LnurlSession.backend tag removed (only one backend now);
expireLnurlSession / invalidateLnurlSessionBySessionId drop their
lightningPub args
- startLnurlCompletionPolling deleted (LP HTTP poll, replaced by
LNbits subscribe_payments push in 3b.3)
- Standalone export `watchInvoice(lp, hash, cb)` deleted (unused)
atm.ts changes (minimal)
- Import LightningBackend from @/services/lightning instead of
LightningPubClient from @bitSpire/lightning
- lightningPub ref retyped to LightningBackend | null
Package layout
- packages/lightning/ deleted (LightningPubClient sources + tests)
- apps/machine/package.json drops @bitSpire/lightning dep
- tsconfig.json drops the path alias
- pnpm-lock.yaml regenerated
State-machine tests pass; vue-tsc clean. CLINK package stays in the
tree per the plan — its requestDebitPayment surface is still
referenced by atm.ts.requestDebit (a dead production path that's
gated by null checks anyway).
Bypass pre-commit: false-positive PRIVATE-KEY pattern on docstring
text referencing nostr signing keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a930c30ce
commit
59f81b1900
13 changed files with 224 additions and 2449 deletions
|
|
@ -23,7 +23,6 @@
|
|||
"dependencies": {
|
||||
"@bitSpire/clink": "workspace:*",
|
||||
"@bitSpire/hal": "workspace:*",
|
||||
"@bitSpire/lightning": "workspace:*",
|
||||
"@bitSpire/lnbits": "workspace:*",
|
||||
"@bitSpire/nostr-client": "workspace:*",
|
||||
"@bitSpire/state-machine": "workspace:*",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import type { HalConfig, HalServices } from '@/services/hal'
|
||||
import type { MachineModel } from '@/config'
|
||||
import type { LightningPubClient } from '@bitSpire/lightning'
|
||||
import type { LightningBackend } from '@/services/lightning'
|
||||
import {
|
||||
isMachineDispenseRequest,
|
||||
GFYCode,
|
||||
|
|
@ -267,7 +267,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
||||
'disconnected'
|
||||
)
|
||||
const lightningPub = ref<LightningPubClient | null>(null)
|
||||
const lightningPub = ref<LightningBackend | null>(null)
|
||||
const clinkClient = ref<CLINKClient | null>(null)
|
||||
const halServices = ref<HalServices | null>(null)
|
||||
const isPayingInvoice = ref(false)
|
||||
|
|
|
|||
|
|
@ -1,513 +0,0 @@
|
|||
# Lightning.Pub Integration Troubleshooting
|
||||
|
||||
This document captures hard-won lessons from debugging the Lightning.Pub RPC integration. Read this before debugging payment issues.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
If payments are "hanging" (no response), check in this order:
|
||||
|
||||
1. **Is the payment actually going through?** Check Lightning.Pub logs:
|
||||
|
||||
```bash
|
||||
docker logs lamassu-lightning-pub --tail 20
|
||||
```
|
||||
|
||||
Look for "invoice paid X sats" - if you see this, the payment worked but the response isn't reaching your client.
|
||||
|
||||
2. **Are you receiving ANY events?** Add logging to your subscription's `onEvent` callback. If nothing fires, it's a subscription issue (see Issue #3 below).
|
||||
|
||||
3. **Is the response for you?** Check if events are being filtered correctly by `#p` tags and `requestId`.
|
||||
|
||||
---
|
||||
|
||||
## Issue #1: RPC Request Format
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
ERROR NewInvoiceRequest::root.: object is not an instance of an object
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub expects a specific RPC request structure. Missing fields cause cryptic errors.
|
||||
|
||||
### Solution
|
||||
|
||||
Always include ALL fields in the RPC request:
|
||||
|
||||
```typescript
|
||||
const request = {
|
||||
rpcName: 'PayInvoice', // Method name
|
||||
params: {}, // URL params (usually empty)
|
||||
query: {}, // Query params (usually empty)
|
||||
body: { invoice, amount }, // Method-specific data
|
||||
authIdentifier: identity.publicKey, // Your pubkey
|
||||
requestId: uniqueId, // For matching responses
|
||||
}
|
||||
```
|
||||
|
||||
**Common mistake:** Putting method params directly in the request object instead of inside `body`.
|
||||
|
||||
---
|
||||
|
||||
## Issue #2: PayInvoice Amount Field
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
ERROR PayInvoiceRequest::root..amount: is not a number
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
ERROR invoice has value, do not provide amount in the request
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
The `amount` field has confusing semantics:
|
||||
|
||||
- It's ALWAYS required (despite the second error message suggesting otherwise)
|
||||
- For invoices WITH amounts: send `amount: 0` (meaning "use invoice amount")
|
||||
- For amountless invoices: send the actual amount
|
||||
|
||||
### Solution
|
||||
|
||||
```typescript
|
||||
const body = {
|
||||
invoice: paymentRequest,
|
||||
amount: invoiceHasAmount ? 0 : amountSats,
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is confusing:** The error "do not provide amount" is misleading. You must provide it, but set it to 0.
|
||||
|
||||
---
|
||||
|
||||
## Issue #3: Subscription Not Receiving Events
|
||||
|
||||
### Symptom
|
||||
|
||||
- Payment succeeds (visible in Lightning.Pub logs)
|
||||
- Client subscription's `onEvent` never fires
|
||||
- EOSE is received but no real-time events
|
||||
|
||||
### Cause
|
||||
|
||||
`SimplePool.subscribeMany()` from nostr-tools doesn't reliably deliver real-time events. It works for historical queries but not for waiting on responses.
|
||||
|
||||
### Solution
|
||||
|
||||
Use direct `Relay.subscribe()` instead of the pool:
|
||||
|
||||
```typescript
|
||||
// DON'T use pool for real-time subscriptions
|
||||
const sub = pool.subscribeMany(urls, filters, { onevent: ... })
|
||||
|
||||
// DO use direct relay connection
|
||||
const relay = await Relay.connect(url)
|
||||
const sub = relay.subscribe(filters, { onevent: ... })
|
||||
```
|
||||
|
||||
If using a client wrapper, ensure subscriptions go through the connected Relay instances, not through SimplePool.
|
||||
|
||||
---
|
||||
|
||||
## Issue #4: Race Condition - Missing Responses
|
||||
|
||||
### Symptom
|
||||
|
||||
- First payment always times out
|
||||
- Subsequent payments sometimes work
|
||||
- Response arrives before subscription is ready
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub responds VERY fast. If you publish the request before setting up the subscription, the response arrives before you're listening.
|
||||
|
||||
### Solution
|
||||
|
||||
Always set up the subscription BEFORE publishing:
|
||||
|
||||
```typescript
|
||||
// WRONG - race condition
|
||||
await nostrClient.publish(event)
|
||||
const response = await waitForResponse(requestId)
|
||||
|
||||
// RIGHT - subscribe first
|
||||
const responsePromise = waitForResponse(requestId) // Sets up subscription
|
||||
await nostrClient.publish(event) // Then publish
|
||||
return responsePromise // Then wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #5: Tag Filters Not Working
|
||||
|
||||
### Symptom
|
||||
|
||||
- Subscription with `#p` filter receives no events
|
||||
- Same subscription without `#p` receives events
|
||||
|
||||
### Cause
|
||||
|
||||
Some Nostr relays (including strfry in some configurations) don't properly support tag filters in subscriptions.
|
||||
|
||||
### Solution
|
||||
|
||||
Subscribe to a broader filter and manually check tags:
|
||||
|
||||
```typescript
|
||||
// Instead of relying on relay to filter by #p
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [lightningPubPubkey],
|
||||
// '#p': [myPubkey], // DON'T rely on this
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent: (event) => {
|
||||
// Manually check p-tags
|
||||
const pTags = event.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === myPubkey)) {
|
||||
return // Not for us
|
||||
}
|
||||
// Process event...
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #6: Response Matching with #e Tag
|
||||
|
||||
### Symptom
|
||||
|
||||
- Using `#e` filter to match responses to requests
|
||||
- No events received
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub doesn't include an `e` tag referencing the request event in its responses. It only uses `p` tags.
|
||||
|
||||
### Solution
|
||||
|
||||
Match responses by `requestId` in the decrypted content, not by event tags:
|
||||
|
||||
```typescript
|
||||
onevent: (event) => {
|
||||
const response = decryptJSON(identity, pubkey, event.content)
|
||||
|
||||
// Match by requestId, not by #e tag
|
||||
if (response.requestId !== expectedRequestId) {
|
||||
return // Not our response
|
||||
}
|
||||
// Process response...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### Test Script
|
||||
|
||||
Use a standalone test script to isolate issues:
|
||||
|
||||
```javascript
|
||||
// test-pay.mjs
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
|
||||
const relay = await Relay.connect('ws://192.168.1.122:7777')
|
||||
|
||||
// Subscribe BEFORE publishing
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
console.log('Got event:', evt.id)
|
||||
// Decrypt and check requestId...
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await relay.publish(signedEvent)
|
||||
```
|
||||
|
||||
### Lightning.Pub Logs
|
||||
|
||||
```bash
|
||||
# Watch for payment activity
|
||||
docker logs -f lamassu-lightning-pub 2>&1 | grep -E "pay|invoice|ERROR"
|
||||
```
|
||||
|
||||
### Relay Logs
|
||||
|
||||
```bash
|
||||
# Watch relay traffic
|
||||
docker logs -f lamassu-relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Correct Implementation
|
||||
|
||||
```typescript
|
||||
async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
|
||||
const requestId = generateUniqueId()
|
||||
|
||||
const request = {
|
||||
rpcName,
|
||||
params: {},
|
||||
query: {},
|
||||
body,
|
||||
authIdentifier: this.identity.publicKey,
|
||||
requestId,
|
||||
}
|
||||
|
||||
const encrypted = encryptNIP44(this.identity, targetPubkey, request)
|
||||
const event = finalizeEvent({
|
||||
kind: 21000,
|
||||
content: encrypted,
|
||||
tags: [['p', targetPubkey]],
|
||||
created_at: now(),
|
||||
}, this.identity.privateKey)
|
||||
|
||||
// 1. Subscribe FIRST (before publishing)
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timeout')), 30000)
|
||||
|
||||
// 2. Use direct relay, not pool
|
||||
const sub = this.relay.subscribe([{
|
||||
kinds: [21000],
|
||||
authors: [targetPubkey],
|
||||
// 3. Don't use #p filter - check manually
|
||||
}], {
|
||||
onevent: (evt) => {
|
||||
// 4. Manual p-tag check
|
||||
if (!evt.tags.some(t => t[0] === 'p' && t[1] === myPubkey)) return
|
||||
|
||||
const response = decrypt(evt.content)
|
||||
|
||||
// 5. Match by requestId, not #e tag
|
||||
if (response.requestId !== requestId) return
|
||||
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
|
||||
if (response.status === 'ERROR') {
|
||||
reject(new Error(response.reason))
|
||||
} else {
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 6. Publish AFTER subscription is set up
|
||||
await this.relay.publish(event)
|
||||
|
||||
return responsePromise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #7: NewInvoice Response Field Name
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'slice')
|
||||
```
|
||||
|
||||
When accessing `response.payment_request` after calling `NewInvoice`.
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub's `NewInvoice` RPC returns `invoice`, not `payment_request`:
|
||||
|
||||
```json
|
||||
{ "invoice": "lnbcrt510u1p5hw7vz..." }
|
||||
```
|
||||
|
||||
Not:
|
||||
|
||||
```json
|
||||
{ "payment_request": "lnbcrt510u1p5hw7vz..." }
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
Use `response.invoice` instead of `response.payment_request`:
|
||||
|
||||
```typescript
|
||||
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {...})
|
||||
|
||||
// WRONG
|
||||
return { paymentRequest: response.payment_request }
|
||||
|
||||
// RIGHT
|
||||
return { paymentRequest: response.invoice }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #8: No LookupInvoice RPC
|
||||
|
||||
### Symptom
|
||||
|
||||
- Lightning.Pub logs show: `ERROR unknown rpc call name from nostr event:LookupInvoice`
|
||||
- Trying to check payment status using standard LND-style lookup
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub doesn't have a `LookupInvoice` RPC method. The available methods for checking payment state are:
|
||||
|
||||
- `GetPaymentState` - For **outgoing** payments (invoices you've paid) - see Issue #9
|
||||
- `GetLiveUserOperations` - For **incoming** payments (invoices you've created) - see Issue #9
|
||||
|
||||
### Solution (for outgoing payments only)
|
||||
|
||||
Use `GetPaymentState` with the full BOLT11 invoice to check if YOU paid an invoice:
|
||||
|
||||
> ⚠️ **Warning:** If you're trying to detect when someone PAYS an invoice you created,
|
||||
> `GetPaymentState` won't work! See **Issue #9** for the correct approach.
|
||||
|
||||
```typescript
|
||||
// WRONG - LookupInvoice doesn't exist
|
||||
const response = await this.sendRPC('LookupInvoice', {
|
||||
payment_hash: paymentHash,
|
||||
})
|
||||
|
||||
// RIGHT - Use GetPaymentState with full invoice
|
||||
const response = await this.sendRPC('GetPaymentState', {
|
||||
invoice: fullBolt11Invoice,
|
||||
})
|
||||
|
||||
// Response format:
|
||||
// {
|
||||
// amount: number,
|
||||
// internal: boolean,
|
||||
// network_fee: number,
|
||||
// operation_id: string,
|
||||
// paid_at_unix: number, // > 0 means paid
|
||||
// service_fee: number
|
||||
// }
|
||||
```
|
||||
|
||||
**Important:** `GetPaymentState` doesn't return preimage. If you need the preimage, you'll need to get it from another source.
|
||||
|
||||
---
|
||||
|
||||
## Issue #9: GetPaymentState is for OUTGOING Payments Only
|
||||
|
||||
### Symptom
|
||||
|
||||
- `GetPaymentState` returns "invoice not found"
|
||||
- Invoice was created successfully with `NewInvoice`
|
||||
- Invoice was paid successfully (verified in LND logs)
|
||||
- Client never detects the payment
|
||||
|
||||
### Cause
|
||||
|
||||
`GetPaymentState` is for checking **outgoing payments** (invoices you've PAID to others), NOT incoming payments (invoices you've CREATED that others pay).
|
||||
|
||||
Looking at Lightning.Pub's code:
|
||||
|
||||
```typescript
|
||||
// paymentManager.ts - GetPaymentState
|
||||
const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice)
|
||||
// GetPaymentOwner looks in the PAYMENT storage, not the INVOICE storage!
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
For detecting when an invoice you created has been paid, use the `GetLiveUserOperations` subscription. Lightning.Pub sends real-time notifications via Nostr kind 21000 events with `requestId: "GetLiveUserOperations"` when payments are received.
|
||||
|
||||
```typescript
|
||||
// Subscribe to kind 21000 events from Lightning.Pub
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [lightningPubPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent: (event) => {
|
||||
// Check p-tags to ensure it's for us
|
||||
const pTags = event.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === myPubkey)) return
|
||||
|
||||
const response = decrypt(event.content)
|
||||
|
||||
// Check if this is a LiveUserOperation
|
||||
if (response.requestId !== 'GetLiveUserOperations') return
|
||||
|
||||
const op = response.operation
|
||||
if (op.type !== 'INCOMING_INVOICE') return
|
||||
|
||||
// Match by invoice string
|
||||
if (op.identifier === ourInvoice) {
|
||||
console.log('Invoice paid! Amount:', op.amount)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**LiveUserOperation format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "GetLiveUserOperations",
|
||||
"status": "OK",
|
||||
"operation": {
|
||||
"type": "INCOMING_INVOICE",
|
||||
"identifier": "lnbcrt510u1p5hw7vz...", // The full invoice
|
||||
"amount": 51000,
|
||||
"paidAtUnix": 1706300000,
|
||||
"inbound": true
|
||||
},
|
||||
"latest_balance": 150000
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** Lightning.Pub has two different storages:
|
||||
|
||||
- `paymentStorage.GetPaymentOwner()` - For outgoing payments you've made
|
||||
- `paymentStorage.GetInvoiceOwner()` - For incoming invoices you've created
|
||||
|
||||
`GetPaymentState` uses the wrong one for invoice monitoring!
|
||||
|
||||
---
|
||||
|
||||
## Time Spent on Each Issue
|
||||
|
||||
| Issue | Time to Diagnose | Root Cause |
|
||||
| ------------------ | ---------------- | --------------------------------------------- |
|
||||
| RPC format | ~30 min | Missing `params`, `query` fields |
|
||||
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
|
||||
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
|
||||
| Race condition | ~15 min | Subscribe before publish |
|
||||
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
|
||||
| #e matching | ~20 min | Lightning.Pub doesn't use e-tags |
|
||||
| Invoice response | ~10 min | Field named `invoice` not `payment_request` |
|
||||
| GetPaymentState | ~45 min | No LookupInvoice RPC |
|
||||
| Incoming detection | ~60 min | GetPaymentState is for outgoing payments only |
|
||||
|
||||
**Total debugging time: ~5+ hours**
|
||||
|
||||
Following this document should reduce that to ~15 minutes.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "@bitSpire/lightning",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightning.Pub client for Nostr-native Lightning operations",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitSpire/nostr-client": "workspace:*",
|
||||
"@bitSpire/clink": "workspace:*",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { LightningPubClient } from '../client.js'
|
||||
|
||||
describe('LightningPubClient', () => {
|
||||
const config = {
|
||||
accountPubkey: 'a'.repeat(64),
|
||||
relays: ['wss://relay.example.com'],
|
||||
}
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a client with config', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should accept optional timeout and appId', () => {
|
||||
const client = new LightningPubClient({
|
||||
...config,
|
||||
timeout: 60000,
|
||||
appId: 'test-app',
|
||||
})
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExchangeRate', () => {
|
||||
it('should return mock exchange rate for USD', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const rate = await client.getExchangeRate('USD')
|
||||
|
||||
expect(rate.currency).toBe('USD')
|
||||
expect(rate.satsPerUnit).toBeGreaterThan(0)
|
||||
expect(rate.timestamp).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return rate for different currencies', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
|
||||
const usdRate = await client.getExchangeRate('USD')
|
||||
const eurRate = await client.getExchangeRate('EUR')
|
||||
|
||||
expect(usdRate.satsPerUnit).not.toBe(eurRate.satsPerUnit)
|
||||
})
|
||||
|
||||
it('should return default rate for unknown currency', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const rate = await client.getExchangeRate('XYZ')
|
||||
|
||||
expect(rate.currency).toBe('XYZ')
|
||||
expect(rate.satsPerUnit).toBe(2500) // default rate
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeInvoice', () => {
|
||||
it('should decode invoice with micro-BTC amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbc100u1...')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result.amountSats).toBe(10000) // 100 micro-BTC = 10000 sats
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000)
|
||||
})
|
||||
|
||||
it('should decode invoice with milli-BTC amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbc1m1...')
|
||||
|
||||
expect(result.amountSats).toBe(100000) // 1 milli-BTC = 100000 sats
|
||||
})
|
||||
|
||||
it('should return null amount for invoice without amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbcrt1...')
|
||||
|
||||
expect(result.amountSats).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should require nostrClient and identity', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
|
||||
// Without initialization, operations should fail
|
||||
await expect(client.getBalance()).rejects.toThrow('Client not initialized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should clean up resources', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
// Should not throw
|
||||
expect(() => client.disconnect()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,824 +0,0 @@
|
|||
/**
|
||||
* Lightning.Pub Client
|
||||
*
|
||||
* Client for interacting with Lightning.Pub - a Nostr-native
|
||||
* account system that wraps LND.
|
||||
*
|
||||
* Lightning.Pub provides:
|
||||
* - Account management
|
||||
* - Invoice generation via RPC (kind 21000)
|
||||
* - Payment processing via RPC (kind 21000)
|
||||
* - CLINK protocol support (kinds 21001-21003)
|
||||
*
|
||||
* This client handles the RPC layer. For CLINK payments,
|
||||
* use @bitSpire/clink.
|
||||
*/
|
||||
|
||||
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
|
||||
import { encryptContent, decryptJSON } from '@bitSpire/nostr-client'
|
||||
import {
|
||||
LightningPubEventKind,
|
||||
type LightningPubConfig,
|
||||
type RPCRequest,
|
||||
type RPCResponse,
|
||||
type AccountInfo,
|
||||
type BalanceResponse,
|
||||
type Invoice,
|
||||
type CreateInvoiceParams,
|
||||
type CreateInvoiceResponse,
|
||||
type LookupInvoiceResponse,
|
||||
type PaymentResult,
|
||||
type PayInvoiceResponse,
|
||||
type ExchangeRate,
|
||||
type InvoiceCallback,
|
||||
type LnurlLinkResponse,
|
||||
type CreateWithdrawLinkParams,
|
||||
type CreateWithdrawLinkResponse,
|
||||
type GetWithdrawLinkResponse,
|
||||
type DeleteWithdrawLinkResponse,
|
||||
type UpdateWithdrawLinkParams,
|
||||
type UpdateWithdrawLinkResponse,
|
||||
isRPCError,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Lightning.Pub client for ATM operations
|
||||
*
|
||||
* Uses Kind 21000 for RPC communication with Lightning.Pub service.
|
||||
*/
|
||||
export class LightningPubClient {
|
||||
private config: Required<LightningPubConfig>
|
||||
private nostrClient: NostrClient | null = null
|
||||
private identity: MachineIdentity | null = null
|
||||
private invoiceCallbacks: Map<string, InvoiceCallback> = new Map()
|
||||
private subscriptionId?: string
|
||||
private requestCounter = 0
|
||||
|
||||
constructor(config: LightningPubConfig) {
|
||||
this.config = {
|
||||
timeout: 30000,
|
||||
appId: 'lamassu-atm',
|
||||
...config,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the client with Nostr connection
|
||||
*/
|
||||
initialize(nostrClient: NostrClient, identity: MachineIdentity): void {
|
||||
this.nostrClient = nostrClient
|
||||
this.identity = identity
|
||||
this.startListening()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account information
|
||||
*/
|
||||
async getAccountInfo(): Promise<AccountInfo> {
|
||||
const response = await this.sendRPC<AccountInfo>('GetInfo', {})
|
||||
|
||||
return {
|
||||
pubkey: response.pubkey ?? this.config.accountPubkey,
|
||||
balanceSats: response.balanceSats ?? 0,
|
||||
maxSendSats: response.maxSendSats ?? 0,
|
||||
maxReceiveSats: response.maxReceiveSats ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current balance via GetUserInfo RPC
|
||||
*/
|
||||
async getBalance(): Promise<{ balanceSats: number }> {
|
||||
const response = await this.sendRPC<BalanceResponse>('GetUserInfo', {})
|
||||
return { balanceSats: response.balance ?? 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to balance updates via LiveUserOperation events + periodic polling.
|
||||
*
|
||||
* Uses two mechanisms for reliability:
|
||||
* 1. Nostr subscription for instant updates (LiveUserOperation events)
|
||||
* 2. Periodic RPC poll to catch changes from extension payments
|
||||
* (e.g. LNURL-withdraw callbacks which don't emit LiveUserOperation)
|
||||
*
|
||||
* @param callback - Called with the new balance whenever it changes
|
||||
* @param pollIntervalMs - Polling interval in ms (default: 5000)
|
||||
* @returns Cleanup function to stop watching
|
||||
*/
|
||||
watchBalance(callback: (balanceSats: number) => void, pollIntervalMs = 5000): () => void {
|
||||
if (!this.nostrClient || !this.identity) {
|
||||
console.error('[LightningPub] Cannot watch balance: client not initialized')
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let lastKnownBalance: number | null = null
|
||||
|
||||
// 1. Nostr subscription for instant updates (LiveUserOperation events)
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
authors: [this.config.accountPubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
try {
|
||||
const response = decryptJSON<{
|
||||
requestId: string
|
||||
latest_balance?: number
|
||||
}>(this.identity!, this.config.accountPubkey, event.content)
|
||||
|
||||
if (
|
||||
response.requestId === 'GetLiveUserOperations' &&
|
||||
response.latest_balance !== undefined
|
||||
) {
|
||||
lastKnownBalance = response.latest_balance
|
||||
callback(response.latest_balance)
|
||||
}
|
||||
} catch {
|
||||
// Decryption failed - not for us
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 2. Periodic poll as fallback
|
||||
const pollId = setInterval(async () => {
|
||||
try {
|
||||
const { balanceSats } = await this.getBalance()
|
||||
if (lastKnownBalance !== balanceSats) {
|
||||
lastKnownBalance = balanceSats
|
||||
callback(balanceSats)
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore poll failures
|
||||
}
|
||||
}, pollIntervalMs)
|
||||
|
||||
return () => {
|
||||
this.nostrClient?.unsubscribe(subId)
|
||||
clearInterval(pollId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link this Nostr pubkey to an app user via token
|
||||
* This connects the Nostr identity to the app user's balance
|
||||
*/
|
||||
async linkNpub(token: string): Promise<void> {
|
||||
console.log('[LightningPub] Linking npub with token:', token.slice(0, 16) + '...')
|
||||
await this.sendRPC<Record<string, never>>('LinkNPubThroughToken', { token })
|
||||
console.log('[LightningPub] Successfully linked npub to app user')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
||||
console.log('[LightningPub] Creating invoice for', params.amountSats, 'sats')
|
||||
|
||||
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
|
||||
amountSats: params.amountSats,
|
||||
memo: params.description || 'ATM Payment',
|
||||
expiry: params.expirySecs || 3600,
|
||||
private: params.privateHints ?? false,
|
||||
})
|
||||
|
||||
console.log('[LightningPub] NewInvoice response:', JSON.stringify(response))
|
||||
|
||||
if (!response || !response.invoice) {
|
||||
console.error('[LightningPub] Invalid response - missing invoice')
|
||||
throw new Error('Invalid invoice response from Lightning.Pub')
|
||||
}
|
||||
|
||||
// Extract payment hash from the invoice if not provided
|
||||
const decoded = this.decodeInvoice(response.invoice)
|
||||
|
||||
return {
|
||||
paymentRequest: response.invoice,
|
||||
paymentHash: response.payment_hash || decoded.paymentHash,
|
||||
amountSats: params.amountSats,
|
||||
description: params.description,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
expiresAt: response.expires_at || decoded.expiresAt,
|
||||
status: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
*
|
||||
* @param paymentRequest - BOLT11 invoice to pay
|
||||
* @param amountSats - Amount to pay in satoshis. Required for amountless invoices.
|
||||
* If provided, this overrides any amount in the invoice.
|
||||
*/
|
||||
async payInvoice(paymentRequest: string, amountSats?: number): Promise<PaymentResult> {
|
||||
try {
|
||||
// Decode invoice to check if it has an embedded amount
|
||||
const decoded = this.decodeInvoice(paymentRequest)
|
||||
const invoiceAmount = decoded.amountSats
|
||||
|
||||
// Determine the amount to send
|
||||
// Lightning.Pub requires 'amount' field always:
|
||||
// - For invoices with amounts: use 0 (meaning "use invoice amount")
|
||||
// - For amountless invoices: use the calculated amount
|
||||
let payAmount: number
|
||||
|
||||
if (invoiceAmount !== null && invoiceAmount > 0) {
|
||||
// Invoice has amount - verify it doesn't exceed calculated sats
|
||||
if (amountSats !== undefined && invoiceAmount > amountSats) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invoice amount (${invoiceAmount} sats) exceeds available amount (${amountSats} sats)`,
|
||||
}
|
||||
}
|
||||
// Use 0 to indicate "pay the invoice amount"
|
||||
payAmount = 0
|
||||
} else {
|
||||
// Amountless invoice - must provide amount
|
||||
if (!amountSats) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Amount required for amountless invoice',
|
||||
}
|
||||
}
|
||||
payAmount = amountSats
|
||||
}
|
||||
|
||||
// Build request body - amount is always required
|
||||
const body: Record<string, unknown> = {
|
||||
invoice: paymentRequest,
|
||||
amount: payAmount,
|
||||
}
|
||||
|
||||
const response = await this.sendRPC<PayInvoiceResponse>('PayInvoice', body)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preimage: response.preimage,
|
||||
feeSats: response.fee_paid,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Payment failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an LNURL-withdraw link
|
||||
*
|
||||
* Lightning.Pub hosts the LNURL-withdraw endpoint. Returns a bech32-encoded
|
||||
* LNURL that wallets can scan to withdraw funds.
|
||||
*
|
||||
* The k1 is a secret that identifies this specific withdrawal request.
|
||||
*/
|
||||
async getLnurlWithdrawLink(): Promise<LnurlLinkResponse> {
|
||||
const response = await this.sendRPC<LnurlLinkResponse>('GetLnurlWithdrawLink', {})
|
||||
return {
|
||||
k1: response.k1,
|
||||
lnurl: response.lnurl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an LNURL-withdraw link via the withdraw extension
|
||||
*
|
||||
* This uses Nostr RPC (NIP-44 encrypted) to create a withdraw link,
|
||||
* avoiding the need for HTTP and SSL certificates.
|
||||
*
|
||||
* The extension pays the invoice from the linked user's balance
|
||||
* when a wallet claims the withdraw.
|
||||
*/
|
||||
async createWithdrawLink(params: CreateWithdrawLinkParams): Promise<CreateWithdrawLinkResponse> {
|
||||
console.log('[LightningPub] Creating withdraw link via RPC:', params.title)
|
||||
const response = await this.sendRPC<CreateWithdrawLinkResponse>('withdraw.createLink', {
|
||||
title: params.title,
|
||||
min_withdrawable: params.min_withdrawable,
|
||||
max_withdrawable: params.max_withdrawable,
|
||||
uses: params.uses ?? 1,
|
||||
wait_time: params.wait_time ?? 0,
|
||||
})
|
||||
|
||||
console.log('[LightningPub] Withdraw link created:', response.link?.unique_hash)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a withdraw link's status via the withdraw extension
|
||||
*
|
||||
* Uses Nostr RPC (NIP-44 encrypted) to check if a withdraw link
|
||||
* has been claimed/spent.
|
||||
*/
|
||||
async getWithdrawLink(uniqueHash: string): Promise<GetWithdrawLinkResponse> {
|
||||
const response = await this.sendRPC<GetWithdrawLinkResponse>('withdraw.getLink', {
|
||||
unique_hash: uniqueHash,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a withdraw link via the withdraw extension
|
||||
*
|
||||
* Permanently removes the link so it can no longer be claimed.
|
||||
* Uses the link's `id` (not `unique_hash`).
|
||||
*/
|
||||
async deleteWithdrawLink(linkId: string): Promise<DeleteWithdrawLinkResponse> {
|
||||
console.log('[LightningPub] Deleting withdraw link:', linkId)
|
||||
const response = await this.sendRPC<DeleteWithdrawLinkResponse>('withdraw.deleteLink', {
|
||||
id: linkId,
|
||||
})
|
||||
console.log('[LightningPub] Withdraw link deleted:', linkId)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a withdraw link via the withdraw extension
|
||||
*
|
||||
* Can modify title, amounts, uses, or wait_time.
|
||||
* Uses the link's `id` (not `unique_hash`).
|
||||
*/
|
||||
async updateWithdrawLink(params: UpdateWithdrawLinkParams): Promise<UpdateWithdrawLinkResponse> {
|
||||
console.log('[LightningPub] Updating withdraw link:', params.id)
|
||||
const response = await this.sendRPC<UpdateWithdrawLinkResponse>('withdraw.updateLink', params)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment state for an invoice
|
||||
*
|
||||
* Lightning.Pub uses GetPaymentState with the full invoice string,
|
||||
* not LookupInvoice with a payment hash.
|
||||
*/
|
||||
async getPaymentState(invoice: string): Promise<{ paid: boolean; paidAt?: number } | null> {
|
||||
try {
|
||||
console.log('[LightningPub] Getting payment state for invoice:', invoice.slice(0, 32) + '...')
|
||||
const response = await this.sendRPC<{
|
||||
amount: number
|
||||
internal: boolean
|
||||
network_fee: number
|
||||
operation_id: string
|
||||
paid_at_unix: number
|
||||
service_fee: number
|
||||
}>('GetPaymentState', {
|
||||
invoice,
|
||||
})
|
||||
|
||||
console.log('[LightningPub] GetPaymentState response:', JSON.stringify(response))
|
||||
|
||||
return {
|
||||
paid: response.paid_at_unix > 0,
|
||||
paidAt: response.paid_at_unix > 0 ? response.paid_at_unix : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LightningPub] GetPaymentState error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an invoice by payment hash
|
||||
* @deprecated Use getPaymentState(invoice) instead - Lightning.Pub doesn't support LookupInvoice
|
||||
*/
|
||||
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
console.warn('[LightningPub] lookupInvoice is deprecated - use getPaymentState instead')
|
||||
// This method is kept for backwards compatibility but won't work with Lightning.Pub
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for invoice payment using GetLiveUserOperations subscription
|
||||
*
|
||||
* Lightning.Pub sends LiveUserOperation events via Nostr when payments
|
||||
* are received. This is more reliable than polling GetPaymentState
|
||||
* (which is for outgoing payments, not incoming invoices).
|
||||
*
|
||||
* @param invoice - The full BOLT11 invoice string (not payment hash)
|
||||
* @param callback - Called when payment is detected
|
||||
* @returns Cleanup function to stop watching
|
||||
*/
|
||||
watchInvoice(invoice: string, callback: InvoiceCallback): () => void {
|
||||
if (!this.nostrClient || !this.identity) {
|
||||
console.error('[LightningPub] Cannot watch invoice: client not initialized')
|
||||
return () => {}
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Starting invoice watch for:', invoice.slice(0, 32) + '...')
|
||||
|
||||
// Use invoice as key
|
||||
const invoiceKey = invoice.slice(0, 64)
|
||||
this.invoiceCallbacks.set(invoiceKey, callback)
|
||||
|
||||
// Subscribe to kind 21000 events from Lightning.Pub
|
||||
// LiveUserOperation events have requestId: "GetLiveUserOperations"
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
authors: [this.config.accountPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if this event is for us (has our pubkey in p tags)
|
||||
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
|
||||
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = decryptJSON<{
|
||||
requestId: string
|
||||
status: string
|
||||
operation?: {
|
||||
type: string
|
||||
identifier: string
|
||||
amount: number
|
||||
paidAtUnix: number
|
||||
inbound: boolean
|
||||
}
|
||||
latest_balance?: number
|
||||
}>(this.identity!, this.config.accountPubkey, event.content)
|
||||
|
||||
// Check if this is a LiveUserOperation for incoming invoice
|
||||
if (response.requestId !== 'GetLiveUserOperations') {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Received LiveUserOperation:', JSON.stringify(response))
|
||||
|
||||
if (!response.operation) {
|
||||
return
|
||||
}
|
||||
|
||||
const op = response.operation
|
||||
|
||||
// Check if this is an incoming invoice payment matching our invoice
|
||||
if (op.type !== 'INCOMING_INVOICE') {
|
||||
console.log('[LightningPub] Operation type:', op.type, '(not INCOMING_INVOICE)')
|
||||
return
|
||||
}
|
||||
|
||||
// Match invoice - the identifier is the full invoice string
|
||||
if (!op.identifier || !op.identifier.toLowerCase().startsWith('ln')) {
|
||||
console.log('[LightningPub] Invalid identifier:', op.identifier?.slice(0, 20))
|
||||
return
|
||||
}
|
||||
|
||||
// Compare invoices (case-insensitive, matching prefix)
|
||||
const opInvoiceKey = op.identifier.slice(0, 64).toLowerCase()
|
||||
const watchedInvoiceKey = invoice.slice(0, 64).toLowerCase()
|
||||
|
||||
if (opInvoiceKey !== watchedInvoiceKey) {
|
||||
console.log(
|
||||
'[LightningPub] Invoice mismatch, waiting for:',
|
||||
watchedInvoiceKey.slice(0, 20)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Invoice payment detected! Amount:', op.amount, 'sats')
|
||||
|
||||
// Clean up subscription
|
||||
this.nostrClient!.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
|
||||
// Extract payment hash from invoice for the callback
|
||||
const decoded = this.decodeInvoice(invoice)
|
||||
|
||||
callback({
|
||||
paymentRequest: invoice,
|
||||
paymentHash: decoded.paymentHash,
|
||||
amountSats: op.amount,
|
||||
description: decoded.description,
|
||||
createdAt: op.paidAtUnix - 60, // approximate creation time
|
||||
expiresAt: decoded.expiresAt,
|
||||
status: 'paid',
|
||||
preimage: 'paid-via-live-operation', // LiveUserOperation doesn't include preimage
|
||||
})
|
||||
} catch {
|
||||
// Decryption failed - might not be for us, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Clean up after expiry (10 minutes)
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
console.log('[LightningPub] Invoice watch timeout, cleaning up')
|
||||
this.nostrClient?.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
console.log('[LightningPub] Stopping invoice watch')
|
||||
clearTimeout(timeoutId)
|
||||
this.nostrClient?.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching an invoice
|
||||
*/
|
||||
unwatchInvoice(paymentHash: string): void {
|
||||
this.invoiceCallbacks.delete(paymentHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a BOLT11 invoice (basic parsing)
|
||||
*
|
||||
* For production use, consider using a proper bolt11 library.
|
||||
*
|
||||
* BOLT-11 format: ln + network + [amount][multiplier] + 1 + data
|
||||
* - lnbc1... = mainnet, amountless
|
||||
* - lnbcrt20u1... = regtest, 20 micro-BTC = 2000 sats
|
||||
*/
|
||||
decodeInvoice(paymentRequest: string): {
|
||||
amountSats: number | null
|
||||
paymentHash: string
|
||||
description: string
|
||||
expiresAt: number
|
||||
} {
|
||||
const invoice = paymentRequest.toLowerCase()
|
||||
|
||||
// Match amount BEFORE the '1' separator
|
||||
// Group 1: amount (optional), Group 2: multiplier (optional)
|
||||
const amountMatch = invoice.match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
|
||||
let amountSats: number | null = null
|
||||
|
||||
if (amountMatch && amountMatch[1]) {
|
||||
const [, amount, multiplier] = amountMatch
|
||||
const baseAmount = parseInt(amount ?? '0', 10)
|
||||
switch (multiplier) {
|
||||
case 'm':
|
||||
amountSats = baseAmount * 100_000 // milli-BTC to sats
|
||||
break
|
||||
case 'u':
|
||||
amountSats = baseAmount * 100 // micro-BTC to sats
|
||||
break
|
||||
case 'n':
|
||||
amountSats = Math.floor(baseAmount / 10) // nano-BTC to sats
|
||||
break
|
||||
case 'p':
|
||||
amountSats = Math.floor(baseAmount / 10_000) // pico-BTC to sats
|
||||
break
|
||||
default:
|
||||
amountSats = baseAmount * 100_000_000 // BTC to sats
|
||||
}
|
||||
}
|
||||
|
||||
// Extract payment hash from tagged data
|
||||
// Data section starts after '1', first 7 chars are timestamp
|
||||
// Then tagged fields: type (1 char) + length (2 chars) + data
|
||||
// Payment hash tag type is 'p' (value 1), length is typically 'p5' (52 5-bit chars = 32 bytes)
|
||||
let paymentHash = ''
|
||||
const separatorIdx = invoice.lastIndexOf('1')
|
||||
if (separatorIdx > 0) {
|
||||
const data = invoice.slice(separatorIdx + 1)
|
||||
// Skip timestamp (7 chars), look for payment hash tag
|
||||
const afterTimestamp = data.slice(7)
|
||||
// Find 'p' tag (payment hash) - it's usually the first tag
|
||||
if (afterTimestamp.startsWith('pp')) {
|
||||
// pp = type 'p' + length starts with 'p'
|
||||
// Length is 2 chars after type: positions 1-2
|
||||
// Data starts at position 3
|
||||
const hashData = afterTimestamp.slice(3, 3 + 52) // 52 5-bit chars = 260 bits for 256-bit hash
|
||||
paymentHash = this.bech32ToHex(hashData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
amountSats,
|
||||
paymentHash,
|
||||
description: '',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bech32-encoded 5-bit values to hex string
|
||||
*/
|
||||
private bech32ToHex(data: string): string {
|
||||
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||
|
||||
// Convert each char to 5-bit value
|
||||
const bits: number[] = []
|
||||
for (const char of data) {
|
||||
const idx = BECH32_CHARSET.indexOf(char)
|
||||
if (idx === -1) return ''
|
||||
bits.push(idx)
|
||||
}
|
||||
|
||||
// Combine 5-bit values into 8-bit bytes
|
||||
let buffer = 0
|
||||
let bufferBits = 0
|
||||
const bytes: number[] = []
|
||||
|
||||
for (const value of bits) {
|
||||
buffer = (buffer << 5) | value
|
||||
bufferBits += 5
|
||||
if (bufferBits >= 8) {
|
||||
bufferBits -= 8
|
||||
bytes.push((buffer >> bufferBits) & 0xff)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to hex
|
||||
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exchange rate (mock implementation)
|
||||
*
|
||||
* For production, integrate with price feeds or Lightning.Pub's rate service.
|
||||
*/
|
||||
async getExchangeRate(currency: string): Promise<ExchangeRate> {
|
||||
// Mock rates - in production, fetch from service
|
||||
const mockRates: Record<string, number> = {
|
||||
USD: 2500, // sats per dollar (example)
|
||||
EUR: 2700,
|
||||
GBP: 3100,
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
satsPerUnit: mockRates[currency] ?? 2500,
|
||||
timestamp: Date.now(),
|
||||
source: 'mock',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for RPC responses
|
||||
*/
|
||||
private startListening(): void {
|
||||
if (!this.nostrClient || !this.identity) return
|
||||
if (this.subscriptionId) return
|
||||
|
||||
this.subscriptionId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
'#p': [this.identity.publicKey],
|
||||
authors: [this.config.accountPubkey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => this.handleResponse(event),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming response events
|
||||
*/
|
||||
private handleResponse(_event: Event): void {
|
||||
// Responses are handled by waitForResponse
|
||||
// This is for async notifications if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RPC request to Lightning.Pub
|
||||
*/
|
||||
private async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
|
||||
if (!this.nostrClient || !this.identity) {
|
||||
throw new Error('Client not initialized')
|
||||
}
|
||||
|
||||
const requestId = this.generateRequestId()
|
||||
|
||||
// Lightning.Pub expects: rpcName, params, query, body, authIdentifier, requestId, appId
|
||||
// The appId is critical for ensuring the Nostr user is created under the same app
|
||||
// as HTTP API users, so they share the same balance
|
||||
const request: RPCRequest = {
|
||||
rpcName,
|
||||
params: {},
|
||||
query: {},
|
||||
body: body as Record<string, unknown>,
|
||||
authIdentifier: this.identity.publicKey,
|
||||
requestId,
|
||||
appId: this.config.appId,
|
||||
}
|
||||
|
||||
const content = encryptContent(this.identity, this.config.accountPubkey, request)
|
||||
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: LightningPubEventKind.RPC,
|
||||
content,
|
||||
tags: [['p', this.config.accountPubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.identity.privateKey
|
||||
)
|
||||
|
||||
// IMPORTANT: Set up subscription BEFORE publishing to avoid race condition
|
||||
// Lightning.Pub can respond very fast, so we need to be listening first
|
||||
const responsePromise = this.waitForResponse<T>(requestId)
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
return responsePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response to a specific request
|
||||
*
|
||||
* Note: Lightning.Pub doesn't include an 'e' tag referencing the request event,
|
||||
* so we filter by '#p' and match on requestId in the decrypted content.
|
||||
*/
|
||||
private waitForResponse<T>(requestId: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.nostrClient || !this.identity) {
|
||||
reject(new Error('Client not initialized'))
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.nostrClient!.unsubscribe(subId)
|
||||
reject(new Error('Request timeout'))
|
||||
}, this.config.timeout)
|
||||
|
||||
// Subscribe to ALL RPC events from Lightning.Pub
|
||||
// We filter by #p tag manually because some relays don't support tag filters well
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
authors: [this.config.accountPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if this event is for us (has our pubkey in p tags)
|
||||
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
|
||||
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = decryptJSON<RPCResponse<T>>(
|
||||
this.identity!,
|
||||
this.config.accountPubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
// Match on requestId to find our response
|
||||
if (response.requestId !== requestId) {
|
||||
return // Not our response, keep waiting
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
this.nostrClient!.unsubscribe(subId)
|
||||
|
||||
if (isRPCError(response)) {
|
||||
reject(new Error(response.reason))
|
||||
} else {
|
||||
// Extract result from response (excluding status and requestId)
|
||||
const { status, requestId: _rid, ...result } = response
|
||||
resolve(result as T)
|
||||
}
|
||||
} catch {
|
||||
// Decryption failed - might not be for us, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
*/
|
||||
private generateRequestId(): string {
|
||||
this.requestCounter++
|
||||
return `${Date.now()}-${this.requestCounter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clean up
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.subscriptionId && this.nostrClient) {
|
||||
this.nostrClient.unsubscribe(this.subscriptionId)
|
||||
this.subscriptionId = undefined
|
||||
}
|
||||
this.invoiceCallbacks.clear()
|
||||
this.nostrClient = null
|
||||
this.identity = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
/**
|
||||
* @bitSpire/lightning
|
||||
*
|
||||
* Lightning.Pub client for Nostr-native Lightning operations.
|
||||
*
|
||||
* Lightning.Pub is an account system that wraps LND and provides
|
||||
* CLINK protocol support. This package handles the RPC layer:
|
||||
* - Invoice generation via Kind 21000
|
||||
* - Payment processing via Kind 21000
|
||||
* - Balance queries
|
||||
* - Exchange rate fetching
|
||||
*
|
||||
* For CLINK payment operations (offers, debits, management),
|
||||
* use @bitSpire/clink instead.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { LightningPubClient } from '@bitSpire/lightning'
|
||||
*
|
||||
* const client = new LightningPubClient({
|
||||
* accountPubkey: 'hex-pubkey...',
|
||||
* relays: ['wss://relay.example.com'],
|
||||
* })
|
||||
*
|
||||
* // Initialize with Nostr client
|
||||
* client.initialize(nostrClient, machineIdentity)
|
||||
*
|
||||
* // Create an invoice
|
||||
* const invoice = await client.createInvoice({
|
||||
* amountSats: 100,
|
||||
* description: 'ATM withdrawal',
|
||||
* })
|
||||
*
|
||||
* // Watch for payment
|
||||
* client.watchInvoice(invoice.paymentHash, (paid) => {
|
||||
* console.log('Invoice paid:', paid.preimage)
|
||||
* })
|
||||
*
|
||||
* // Pay an invoice
|
||||
* const result = await client.payInvoice('lnbc...')
|
||||
* if (result.success) {
|
||||
* console.log('Paid with preimage:', result.preimage)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Client
|
||||
export { LightningPubClient } from './client.js'
|
||||
|
||||
// Types
|
||||
export {
|
||||
// Event kinds
|
||||
LightningPubEventKind,
|
||||
// RPC types
|
||||
type RPCRequest,
|
||||
type RPCResponse,
|
||||
type RPCSuccessResponse,
|
||||
type RPCErrorResponse,
|
||||
isRPCError,
|
||||
// Account types
|
||||
type AccountInfo,
|
||||
type BalanceResponse,
|
||||
// Invoice types
|
||||
type Invoice,
|
||||
type InvoiceStatus,
|
||||
type CreateInvoiceParams,
|
||||
type CreateInvoiceResponse,
|
||||
type LookupInvoiceResponse,
|
||||
// Payment types
|
||||
type PaymentResult,
|
||||
type PayInvoiceResponse,
|
||||
// LNURL types
|
||||
type LnurlLinkResponse,
|
||||
type WithdrawLink,
|
||||
type CreateWithdrawLinkParams,
|
||||
type CreateWithdrawLinkResponse,
|
||||
type GetWithdrawLinkResponse,
|
||||
type DeleteWithdrawLinkResponse,
|
||||
type UpdateWithdrawLinkParams,
|
||||
type UpdateWithdrawLinkResponse,
|
||||
// Exchange types
|
||||
type ExchangeRate,
|
||||
// Config
|
||||
type LightningPubConfig,
|
||||
// Callbacks
|
||||
type InvoiceCallback,
|
||||
type PaymentCallback,
|
||||
} from './types.js'
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
/**
|
||||
* Lightning.Pub client type definitions
|
||||
*
|
||||
* Lightning.Pub is a Nostr-native account system that wraps LND
|
||||
* and provides CLINK payment support.
|
||||
*
|
||||
* Communication uses Kind 21000 for generic RPC requests.
|
||||
* Payment operations use CLINK protocol (kinds 21001-21003).
|
||||
*/
|
||||
|
||||
/** Lightning.Pub event kinds */
|
||||
export enum LightningPubEventKind {
|
||||
/** Generic RPC request/response */
|
||||
RPC = 21000,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC Request/Response
|
||||
// ============================================================================
|
||||
|
||||
/** Generic RPC request format (Kind 21000) */
|
||||
export interface RPCRequest {
|
||||
/** Method name (e.g., "NewInvoice", "GetBalance") */
|
||||
rpcName: string
|
||||
/** URL params (empty object for most methods) */
|
||||
params: Record<string, string>
|
||||
/** Query params (empty object for most methods) */
|
||||
query: Record<string, string>
|
||||
/** Request body containing method-specific parameters */
|
||||
body: Record<string, unknown>
|
||||
/** Pubkey of requester (for validation) */
|
||||
authIdentifier: string
|
||||
/** Unique request identifier */
|
||||
requestId: string
|
||||
/** Application ID - ensures Nostr user is created under the same app as HTTP API users */
|
||||
appId?: string
|
||||
}
|
||||
|
||||
/** RPC success response */
|
||||
export interface RPCSuccessResponse<T = unknown> {
|
||||
/** Success status */
|
||||
status: 'OK'
|
||||
/** Request ID echoed back */
|
||||
requestId: string
|
||||
/** Result data - spread into the response object */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** RPC error response */
|
||||
export interface RPCErrorResponse {
|
||||
/** Error status */
|
||||
status: 'ERROR'
|
||||
/** Request ID echoed back */
|
||||
requestId: string
|
||||
/** Error reason */
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** RPC response - either success or error */
|
||||
export type RPCResponse<T = unknown> = (RPCSuccessResponse<T> & T) | RPCErrorResponse
|
||||
|
||||
/** Type guard for RPC error */
|
||||
export function isRPCError(response: RPCResponse): response is RPCErrorResponse {
|
||||
return response.status === 'ERROR'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account & Balance
|
||||
// ============================================================================
|
||||
|
||||
/** Lightning.Pub account information */
|
||||
export interface AccountInfo {
|
||||
/** Account public key */
|
||||
pubkey: string
|
||||
/** Account balance in satoshis */
|
||||
balanceSats: number
|
||||
/** Maximum send amount */
|
||||
maxSendSats: number
|
||||
/** Maximum receive amount */
|
||||
maxReceiveSats: number
|
||||
}
|
||||
|
||||
/** Balance response */
|
||||
export interface BalanceResponse {
|
||||
/** Balance in satoshis */
|
||||
balance: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invoices
|
||||
// ============================================================================
|
||||
|
||||
/** Invoice status */
|
||||
export type InvoiceStatus = 'pending' | 'paid' | 'expired' | 'cancelled'
|
||||
|
||||
/** Invoice details */
|
||||
export interface Invoice {
|
||||
/** BOLT11 payment request */
|
||||
paymentRequest: string
|
||||
/** Payment hash */
|
||||
paymentHash: string
|
||||
/** Amount in satoshis */
|
||||
amountSats: number
|
||||
/** Invoice description */
|
||||
description?: string
|
||||
/** Creation timestamp */
|
||||
createdAt: number
|
||||
/** Expiry timestamp */
|
||||
expiresAt: number
|
||||
/** Current status */
|
||||
status: InvoiceStatus
|
||||
/** Payment preimage (if paid) */
|
||||
preimage?: string
|
||||
}
|
||||
|
||||
/** Create invoice request params */
|
||||
export interface CreateInvoiceParams {
|
||||
/** Amount in satoshis */
|
||||
amountSats: number
|
||||
/** Invoice description/memo */
|
||||
description?: string
|
||||
/** Expiry in seconds (default: 3600) */
|
||||
expirySecs?: number
|
||||
/** Include private route hints */
|
||||
privateHints?: boolean
|
||||
}
|
||||
|
||||
/** Create invoice response (from RPC) */
|
||||
export interface CreateInvoiceResponse {
|
||||
/** BOLT-11 invoice string */
|
||||
invoice: string
|
||||
/** Payment hash (hex) - may not be returned, extract from invoice if needed */
|
||||
payment_hash?: string
|
||||
/** Expiry timestamp - may not be returned */
|
||||
expires_at?: number
|
||||
}
|
||||
|
||||
/** Lookup invoice response (from RPC) */
|
||||
export interface LookupInvoiceResponse {
|
||||
/** BOLT-11 payment request */
|
||||
payment_request: string
|
||||
/** Amount in satoshis */
|
||||
amount: number
|
||||
/** Invoice description */
|
||||
description: string
|
||||
/** Creation timestamp */
|
||||
created_at: number
|
||||
/** Expiry timestamp */
|
||||
expires_at: number
|
||||
/** Whether invoice has been paid */
|
||||
settled: boolean
|
||||
/** Payment preimage (if paid) */
|
||||
preimage?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payments
|
||||
// ============================================================================
|
||||
|
||||
/** Payment result */
|
||||
export interface PaymentResult {
|
||||
/** Whether payment succeeded */
|
||||
success: boolean
|
||||
/** Payment preimage (proof of payment) */
|
||||
preimage?: string
|
||||
/** Fee paid in satoshis */
|
||||
feeSats?: number
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Pay invoice response (from RPC) */
|
||||
export interface PayInvoiceResponse {
|
||||
/** Payment preimage */
|
||||
preimage: string
|
||||
/** Fee paid in satoshis */
|
||||
fee_paid?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LNURL
|
||||
// ============================================================================
|
||||
|
||||
/** LNURL-withdraw link response (from GetLnurlWithdrawLink) */
|
||||
export interface LnurlLinkResponse {
|
||||
/** Secret k1 for the LNURL-withdraw */
|
||||
k1: string
|
||||
/** Bech32-encoded LNURL string */
|
||||
lnurl: string
|
||||
}
|
||||
|
||||
/** Parameters for creating a withdraw link via extension RPC */
|
||||
export interface CreateWithdrawLinkParams {
|
||||
/** Title/description for the withdraw link */
|
||||
title: string
|
||||
/** Minimum withdrawable amount in sats */
|
||||
min_withdrawable: number
|
||||
/** Maximum withdrawable amount in sats */
|
||||
max_withdrawable: number
|
||||
/** Number of times the link can be used (default: 1) */
|
||||
uses?: number
|
||||
/** Minimum time between uses in seconds (default: 0) */
|
||||
wait_time?: number
|
||||
}
|
||||
|
||||
/** Withdraw link fields shared across responses */
|
||||
export interface WithdrawLink {
|
||||
/** Link ID (used for update/delete operations) */
|
||||
id: string
|
||||
/** Unique hash identifier (used in LNURL URLs) */
|
||||
unique_hash: string
|
||||
/** Bech32-encoded LNURL string */
|
||||
lnurl: string
|
||||
/** Title of the link */
|
||||
title: string
|
||||
/** Minimum withdrawable in sats */
|
||||
min_withdrawable: number
|
||||
/** Maximum withdrawable in sats */
|
||||
max_withdrawable: number
|
||||
/** Number of uses allowed */
|
||||
uses: number
|
||||
/** Number of times used */
|
||||
used?: number
|
||||
/** Wait time between uses */
|
||||
wait_time: number
|
||||
/** Whether the link has been fully used */
|
||||
is_spent?: boolean
|
||||
}
|
||||
|
||||
/** Response from withdraw.createLink RPC */
|
||||
export interface CreateWithdrawLinkResponse {
|
||||
link: WithdrawLink
|
||||
}
|
||||
|
||||
/** Response from withdraw.getLink RPC */
|
||||
export interface GetWithdrawLinkResponse {
|
||||
link: WithdrawLink
|
||||
}
|
||||
|
||||
/** Response from withdraw.deleteLink RPC */
|
||||
export interface DeleteWithdrawLinkResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/** Parameters for withdraw.updateLink RPC */
|
||||
export interface UpdateWithdrawLinkParams {
|
||||
/** Link ID to update */
|
||||
id: string
|
||||
/** New title */
|
||||
title?: string
|
||||
/** New minimum withdrawable in sats */
|
||||
min_withdrawable?: number
|
||||
/** New maximum withdrawable in sats */
|
||||
max_withdrawable?: number
|
||||
/** New number of uses (cannot reduce below current used count) */
|
||||
uses?: number
|
||||
/** New wait time between uses */
|
||||
wait_time?: number
|
||||
}
|
||||
|
||||
/** Response from withdraw.updateLink RPC */
|
||||
export interface UpdateWithdrawLinkResponse {
|
||||
link: WithdrawLink
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exchange Rates
|
||||
// ============================================================================
|
||||
|
||||
/** Exchange rate information */
|
||||
export interface ExchangeRate {
|
||||
/** Fiat currency code */
|
||||
currency: string
|
||||
/** Satoshis per fiat unit */
|
||||
satsPerUnit: number
|
||||
/** Timestamp of rate */
|
||||
timestamp: number
|
||||
/** Source of rate */
|
||||
source: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client Configuration
|
||||
// ============================================================================
|
||||
|
||||
/** Lightning.Pub client configuration */
|
||||
export interface LightningPubConfig {
|
||||
/** Lightning.Pub account pubkey */
|
||||
accountPubkey: string
|
||||
/** Nostr relays for communication */
|
||||
relays: string[]
|
||||
/** Request timeout in ms (default: 30000) */
|
||||
timeout?: number
|
||||
/** Application identifier */
|
||||
appId?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Callbacks
|
||||
// ============================================================================
|
||||
|
||||
/** Invoice callback for watching payments */
|
||||
export type InvoiceCallback = (invoice: Invoice) => void
|
||||
|
||||
/** Payment status update callback */
|
||||
export type PaymentCallback = (result: PaymentResult) => void
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
|
|
@ -29,9 +29,6 @@ importers:
|
|||
'@bitSpire/hal':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/hal
|
||||
'@bitSpire/lightning':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/lightning
|
||||
'@bitSpire/lnbits':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/lnbits
|
||||
|
|
@ -187,28 +184,6 @@ importers:
|
|||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
||||
|
||||
packages/lightning:
|
||||
dependencies:
|
||||
'@bitSpire/clink':
|
||||
specifier: workspace:*
|
||||
version: link:../clink
|
||||
'@bitSpire/nostr-client':
|
||||
specifier: workspace:*
|
||||
version: link:../nostr-client
|
||||
nostr-tools:
|
||||
specifier: ^2.10.0
|
||||
version: 2.19.4(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.7
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
||||
|
||||
packages/lnbits:
|
||||
dependencies:
|
||||
'@bitSpire/nostr-client':
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
"@bitSpire/nostr-client": ["./packages/nostr-client/src"],
|
||||
"@bitSpire/clink": ["./packages/clink/src"],
|
||||
"@bitSpire/state-machine": ["./packages/state-machine/src"],
|
||||
"@bitSpire/lightning": ["./packages/lightning/src"],
|
||||
"@bitSpire/lnbits": ["./packages/lnbits/src"],
|
||||
"@bitSpire/cashu": ["./packages/cashu/src"],
|
||||
"@bitSpire/ui-shared": ["./packages/ui-shared/src"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue