Compare commits

...

92 commits

Author SHA1 Message Date
Patrick Mulligan
8c8f2d4d3d fix(lnd): allow self-payments for LNURL-withdraw
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
When the user's wallet (e.g. Zeus) is connected to the same LND node
that LP uses, LNURL-withdraw fails because LND rejects the payment
with "no self-payments allowed". This is safe because LP always
decrements the user's balance before paying and refunds on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:45 -05:00
Patrick Mulligan
f8d17a91a7 feat(extensions): pay from caller's balance via PayAppUserInvoice
When userPubkey is provided, resolve the ApplicationUser and call
applicationManager.PayAppUserInvoice instead of paymentManager.PayInvoice
directly. This ensures notifyAppUserPayment fires, sending
LiveUserOperation events via Nostr for real-time balance updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:45 -05:00
Patrick Mulligan
121c22c4b8 feat(withdraw): track creator pubkey on withdraw links
Store the Nostr pubkey of the user who creates a withdraw link so the
LNURL callback debits the correct user's balance instead of the app
owner's. Pass userPubkey through from RPC handler to WithdrawManager.

- Add creator_pubkey column (migration v4)
- Store creatorPubkey on link creation
- Pass creator_pubkey to payInvoice on LNURL callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:45 -05:00
Patrick Mulligan
03f78c0362 feat: route Nostr RPC to extension methods
Initialize extension system before nostrMiddleware so registered
RPC methods are available. Extension methods (e.g. withdraw.createLink)
are intercepted and routed to the extension loader before falling
through to the standard nostrTransport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
df2df9bc26 feat(withdraw): add HTTP API for creating withdraw links
Add POST /api/v1/withdraw/create endpoint to allow external apps (ATM,
web clients) to create LNURL-withdraw links via HTTP instead of RPC.

Changes:
- Add handleCreateWithdrawLink HTTP handler
- Fix route ordering: callback routes before wildcard :unique_hash
- Extract app_id from Authorization header (Bearer app_<id>)
- Use is_unique=false for simple single-use ATM links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
5ecb8166b1 feat(server): add CORS support for extension HTTP routes
Enable CORS on the extension HTTP server to allow cross-origin requests
from ATM apps and other web-based clients.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
dcfb9caa67 feat: integrate extension system with withdraw extension support
- Add extension loader initialization to startup
- Create mainHandlerAdapter to bridge mainHandler with extension context
- Mount extension HTTP routes on separate port (main port + 1)
- Configure EXTENSION_SERVICE_URL for LNURL link generation

The withdraw extension provides LUD-03 LNURL-withdraw support for
creating withdraw links that allow users to pull funds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
39f4575b7c feat(extensions): add LNURL-withdraw extension
Implements LUD-03 (LNURL-withdraw) for creating withdraw links
that allow anyone to pull funds from a Lightning wallet.

Features:
- Create withdraw links with min/max amounts
- Quick vouchers: batch creation of single-use codes
- Multi-use links with wait time between uses
- Unique QR codes per use (prevents sharing exploits)
- Webhook notifications on successful withdrawals
- Full LNURL protocol compliance for wallet compatibility

Use cases:
- Faucets
- Gift cards / prepaid cards
- Tips / donations
- User onboarding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
3d74e468b1 fix: use fresh balance in PayAppUserInvoice notification
notifyAppUserPayment was sending the stale cached balance from the
entity loaded before PayInvoice decremented it. Update the entity's
balance_sats from the PayInvoice response so LiveUserOperation events
contain the correct post-payment balance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
f761196898 chore: update Docker build and dependencies
- Add .dockerignore for runtime state files (sqlite, logs, secrets)
- Bump Node.js base image from 18 to 20
- Add @types/better-sqlite3 dev dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
bc63d30af6 fix: correct nip44v1 secp256k1 getSharedSecret argument types
The @noble/curves secp256k1.getSharedSecret expects Uint8Array arguments,
not hex strings. Use hex.decode() to convert the private and public keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
b6d2143134 feat(extensions): add getLnurlPayInfo to ExtensionContext
Enables extensions to get LNURL-pay info for users by pubkey,
supporting Lightning Address (LUD-16) and zap (NIP-57) functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
3cf74cbc2c docs(extensions): add comprehensive extension loader documentation
Covers architecture, API reference, lifecycle, database isolation,
RPC methods, HTTP routes, event handling, and complete examples.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
93e50c7f76 feat(extensions): add extension loader infrastructure
Adds a modular extension system for Lightning.Pub that allows
third-party functionality to be added without modifying core code.

Features:
- ExtensionLoader: discovers and loads extensions from directory
- ExtensionContext: provides extensions with access to Lightning.Pub APIs
- ExtensionDatabase: isolated SQLite database per extension
- Lifecycle management: initialize, shutdown, health checks
- RPC method registration: extensions can add new RPC methods
- Event dispatching: routes payments and Nostr events to extensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:19:44 -05:00
Patrick Mulligan
72c9872b23 fix(watchdog): handle LND restarts without locking outgoing operations
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
When the payment index advances (e.g. after an LND restart or external
payment), update the cached offset instead of immediately locking.
Only lock if both a history mismatch AND a balance discrepancy are
detected — indicating a real security concern rather than a benign
LND restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:19:20 -05:00
Patrick Mulligan
5e5e30c7a2 fix(lnd): wait for chain/graph sync before marking LND ready
Warmup() previously only checked that LND responded to GetInfo(), but
did not verify syncedToChain/syncedToGraph. This caused LP to accept
requests while LND was still syncing, leading to "not synced" errors
on every Health() check. Now waits for full sync with a 10min timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:18:06 -05:00
Patrick Mulligan
611eb4fc04 fix(nostr): close SimplePool after publishing to prevent connection leak
Each sendEvent() call created a new SimplePool() but never closed it,
causing relay WebSocket connections to accumulate indefinitely (~20/min).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
Patrick Mulligan
6512e10f08 fix(handlers): await NostrSend calls throughout codebase
Update all NostrSend call sites to properly handle the async nature
of the function now that it returns Promise<void>.

Changes:
- handler.ts: Add async to sendResponse, await nostrSend calls
- debitManager.ts: Add logging for Kind 21002 response sending
- nostrMiddleware.ts: Update nostrSend signature
- tlvFilesStorageProcessor.ts: Update nostrSend signature
- webRTC/index.ts: Add async/await for nostrSend calls

This ensures Kind 21002 (ndebit) responses are properly sent to
wallet clients, fixing the "Debit request failed" issue in ShockWallet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
Patrick Mulligan
e9b5dacb3b fix(nostr): update NostrSend type to Promise<void> with error handling
The NostrSend type was incorrectly typed as returning void when it actually
returns Promise<void>. This caused async errors to be silently swallowed.

Changes:
- Update NostrSend type signature to return Promise<void>
- Make NostrSender._nostrSend default to async function
- Add .catch() error handling in NostrSender.Send() to log failures
- Add logging to track event publishing status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 15:16:28 -05:00
Justin (shocknet)
a2b7ac1673
Merge pull request #903 from shocknet/bump-fee-api
bimp fee
2026-03-04 13:27:32 -05:00
boufni95
70544bd551
rate fix 2026-03-04 18:26:21 +00:00
boufni95
74513a1412
bimp fee 2026-03-04 17:57:50 +00:00
Justin (shocknet)
266f526453
Merge pull request #901 from shocknet/bump-fee
bump fee api
2026-03-04 12:38:46 -05:00
boufni95
169284021f
bump fee api 2026-03-04 17:19:42 +00:00
Justin (shocknet)
67f9dfde8b
Merge pull request #900 from shocknet/refund-filed-swps
refund failed swaps
2026-03-03 21:04:18 -05:00
boufni95
7c8cca0a55
refund failed swaps 2026-03-03 19:49:54 +00:00
Justin (shocknet)
0a30cf537f
Merge pull request #899 from shocknet/activate-users-cleanup
fk fix
2026-03-02 15:21:42 -05:00
shocknet-justin
595e5bb257
fk fix 2026-03-02 15:16:08 -05:00
Justin (shocknet)
8576a1d138
Merge pull request #898 from shocknet/activate-users-cleanup
cleanup fix
2026-03-02 15:13:23 -05:00
shocknet-justin
c18b79dc50
use number 2026-03-02 15:10:24 -05:00
shocknet-justin
bfa71f7439
cleanup fix 2026-03-02 15:08:56 -05:00
Justin (shocknet)
a0b77ec1ca
Merge pull request #897 from shocknet/activate-users-cleanup
clean users table
2026-03-02 15:05:45 -05:00
shocknet-justin
cfb7dd1e6e
serial id 2026-03-02 15:01:12 -05:00
shocknet-justin
be6f48427f
clean users table 2026-03-02 14:56:48 -05:00
Justin (shocknet)
21cb960c2e
Merge pull request #896 from shocknet/activate-users-cleanup
cleanup db fix
2026-03-02 14:46:53 -05:00
shocknet-justin
7af841c330
cleanup db fix 2026-03-02 14:44:32 -05:00
Justin (shocknet)
71b55c06d4
Merge pull request #894 from shocknet/activate-users-cleanup
activte cleanup
2026-03-02 14:34:37 -05:00
shocknet-justin
432f9d0b42
bump never active to 90 2026-03-02 14:33:53 -05:00
boufni95
574f229cee
activte cleanup 2026-03-02 18:49:22 +00:00
Justin (shocknet)
f0418fb389
Merge pull request #891 from shocknet/assets_liabilities
assets and liabilities
2026-02-27 00:42:29 -05:00
Justin (shocknet)
dcfa9250fe
Merge pull request #889 from shocknet/tx-swaps
tx swaps polish
2026-02-27 00:42:02 -05:00
boufni95
ef14ec9ddf
assets and liabilities 2026-02-26 21:54:28 +00:00
boufni95
2c8d57dd6e
address amt validation 2026-02-25 18:14:56 +00:00
boufni95
f8fe946b40
tx swaps polish 2026-02-24 18:36:03 +00:00
Justin (shocknet)
e411b7aa7f
Merge pull request #887 from shocknet/test
notification types and topic id
2026-02-20 14:51:58 -05:00
Justin (shocknet)
d949addb16
Merge pull request #886 from shocknet/lnd-log-level
use debug log level,  no level = ERROR
2026-02-20 14:39:17 -05:00
boufni95
9e66f7d72e
use debug log level, no level = ERROR 2026-02-20 19:37:11 +00:00
Justin (shocknet)
ae3b39ee04
Merge pull request #885 from shocknet/fix-pending-tx
fetch each pending tx to validate
2026-02-20 13:46:48 -05:00
boufni95
c83028c419
fetch each pending tx to validate 2026-02-20 18:40:40 +00:00
Mothana
c146d46c59 notification types and topic id 2026-02-20 17:08:38 +04:00
Justin (shocknet)
6aa90d63ba
Merge pull request #884 from shocknet/new-block-logs
new block handler logs
2026-02-19 17:25:13 -05:00
boufni95
8ada2ac165
new block handler logs 2026-02-19 21:45:31 +00:00
Justin (shocknet)
6e8a181d34
Merge pull request #883 from shocknet/missed-tx-process
await tx added to db
2026-02-19 16:38:51 -05:00
boufni95
875d1274de
await tx added to db 2026-02-19 21:24:24 +00:00
Justin (shocknet)
791a5756a7
Merge pull request #882 from shocknet/watchdog-root-ops
account for root ops and change
2026-02-19 13:54:26 -05:00
boufni95
305b268d73
fixes 2026-02-19 18:53:24 +00:00
boufni95
6da8434073
account for root ops and change 2026-02-19 18:11:41 +00:00
Justin (shocknet)
c85968ce2f
Merge pull request #881 from shocknet/paid_at_unix_default
paid at unix default
2026-02-17 16:30:44 -05:00
boufni95
e32dff939e
paid at unix default 2026-02-17 21:29:34 +00:00
Justin (shocknet)
97de703feb
Merge pull request #879 from shocknet/swaps-info
add time info to swaps
2026-02-17 15:23:38 -05:00
shocknet-justin
a10cf126b3
comment 2026-02-17 13:53:02 -05:00
boufni95
edbbbd4aec add time info to swaps 2026-02-17 18:12:33 +00:00
Justin (shocknet)
f4b302ed43
Merge pull request #878 from shocknet/restart-sub-process
restart sub p (reopened for review)
2026-02-17 09:46:01 -05:00
shocknet-justin
c3e3f99c7d
fix subprocess restart issues
- Fix event listener memory leak by removing all listeners before restart
- Properly cleanup old process with SIGTERM before forking new one
- Fix Stop() to actually kill process (was using kill(0) which only checks existence)
- Store callbacks in instance to avoid re-attaching listeners on restart
- Add isShuttingDown flag to prevent restart when stopping intentionally
- Add 100ms delay before restart to ensure clean process termination
- Check if process is killed before sending messages
- Update Reset() to store new settings for future restarts

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 17:22:47 -05:00
boufni95
e271d35c2d
restart sub p 2026-02-12 17:22:47 -05:00
boufni95
3389045de7
missing ops 2026-02-11 15:34:32 -05:00
shocknet-justin
9fe681d73b
swap clear interval 2026-02-11 15:34:32 -05:00
Justin (shocknet)
ecf7c43416
Merge pull request #875 from shocknet/socket-ping-pong
socket ping pong
2026-02-05 15:09:56 -05:00
boufni95
81f7400748 socket ping pong 2026-02-05 20:07:57 +00:00
Justin (shocknet)
c1bcda5654
Merge pull request #874 from shocknet/swap-fixes
swap fixes
2026-02-05 14:25:08 -05:00
boufni95
3a6b22cff8 swap fixes 2026-02-05 19:00:00 +00:00
Justin (shocknet)
a362cb4b91
Merge pull request #869 from shocknet/admin-swaps
admin swaps
2026-02-05 12:05:58 -05:00
boufni95
0a2556d0d4 cleanup tmp fix 2026-02-04 20:30:52 +00:00
shocknet-justin
3046e73464 zkpinit workaround 2026-02-04 15:27:52 -05:00
boufni95
e24fa4f055 better fix 2026-02-04 20:09:15 +00:00
boufni95
d9ec58165f log 2026-02-04 20:03:46 +00:00
boufni95
7b81a90c1a fix 2026-02-04 20:00:01 +00:00
boufni95
1f09bcc679 tmp fix 2026-02-04 19:59:22 +00:00
boufni95
070aa758d8 swap logs 2026-02-04 19:42:53 +00:00
boufni95
23156986b1 handle failure cases 2026-02-04 18:31:37 +00:00
boufni95
f262d46d1f fixes 2026-02-04 17:58:29 +00:00
Justin (shocknet)
02d3580613
Merge pull request #872 from shocknet/lnd_logs
lnd logs
2026-02-04 12:27:28 -05:00
boufni95
ae4a5cb5d0 lnd logs 2026-02-04 16:53:59 +00:00
boufni95
f5eb1f3253 amount input 2026-02-02 21:15:42 +00:00
boufni95
3255730ae2 swap refunds 2026-01-30 20:37:44 +00:00
Justin (shocknet)
e15668b442
Merge pull request #871 from shocknet/handle-child-signal
handle exit signal when code is null
2026-01-28 11:02:53 -05:00
boufni95
3b827626a6 handle exit signal when code is null 2026-01-28 15:53:57 +00:00
boufni95
e71f631993 Merge branch 'master' into admin-swaps 2026-01-27 16:10:28 +00:00
boufni95
d120ad7f99 admin swaps 2026-01-27 16:08:52 +00:00
Justin (shocknet)
44c55f3baa
Merge pull request #870 from sergey3bv/fix/log-doxing
Removing user ID from logs
2026-01-23 13:30:03 -05:00
Sergey B.
03792f3111 fix: removing user ID from logs 2026-01-23 15:30:52 +03:00
boufni95
a596e186fe admin swaps 2026-01-21 16:04:33 +00:00
77 changed files with 9491 additions and 2952 deletions

View file

@ -2,3 +2,21 @@
.github .github
build build
node_modules node_modules
# Runtime state files (should not be baked into image)
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
*.db
admin.connect
admin.enroll
admin.npub
app.nprofile
.jwt_secret
# Runtime data directories
metric_cache/
metric_events/
bundler_events/
logs/

View file

@ -1,4 +1,4 @@
FROM node:18 FROM node:20
WORKDIR /app WORKDIR /app

View file

@ -1,3 +1,14 @@
/**
* TypeORM DataSource used only by the TypeORM CLI (e.g. migration:generate).
*
* Migrations at runtime are run from src/services/storage/migrations/runner.ts (allMigrations),
* not from this file. The app never uses this DataSource to run migrations.
*
* Workflow: update the migrations array in this file *before* running
* migration:generate, so TypeORM knows the current schema (entities + existing migrations).
* We do not update this file immediately after adding a new migration; update it when you
* are about to generate the next migration.
*/
import { DataSource } from "typeorm" import { DataSource } from "typeorm"
import { User } from "./build/src/services/storage/entity/User.js" import { User } from "./build/src/services/storage/entity/User.js"
import { UserReceivingInvoice } from "./build/src/services/storage/entity/UserReceivingInvoice.js" import { UserReceivingInvoice } from "./build/src/services/storage/entity/UserReceivingInvoice.js"
@ -22,11 +33,13 @@ import { AppUserDevice } from "./build/src/services/storage/entity/AppUserDevice
import { UserAccess } from "./build/src/services/storage/entity/UserAccess.js" import { UserAccess } from "./build/src/services/storage/entity/UserAccess.js"
import { AdminSettings } from "./build/src/services/storage/entity/AdminSettings.js" import { AdminSettings } from "./build/src/services/storage/entity/AdminSettings.js"
import { TransactionSwap } from "./build/src/services/storage/entity/TransactionSwap.js" import { TransactionSwap } from "./build/src/services/storage/entity/TransactionSwap.js"
import { InvoiceSwap } from "./build/src/services/storage/entity/InvoiceSwap.js"
import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js' import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js'
import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js' import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js'
import { LndNodeInfo1720187506189 } from './build/src/services/storage/migrations/1720187506189-lnd_node_info.js'
import { LiquidityProvider1719335699480 } from './build/src/services/storage/migrations/1719335699480-liquidity_provider.js' import { LiquidityProvider1719335699480 } from './build/src/services/storage/migrations/1719335699480-liquidity_provider.js'
import { LndNodeInfo1720187506189 } from './build/src/services/storage/migrations/1720187506189-lnd_node_info.js'
import { TrackedProvider1720814323679 } from './build/src/services/storage/migrations/1720814323679-tracked_provider.js'
import { CreateInviteTokenTable1721751414878 } from './build/src/services/storage/migrations/1721751414878-create_invite_token_table.js' import { CreateInviteTokenTable1721751414878 } from './build/src/services/storage/migrations/1721751414878-create_invite_token_table.js'
import { PaymentIndex1721760297610 } from './build/src/services/storage/migrations/1721760297610-payment_index.js' import { PaymentIndex1721760297610 } from './build/src/services/storage/migrations/1721760297610-payment_index.js'
import { DebitAccess1726496225078 } from './build/src/services/storage/migrations/1726496225078-debit_access.js' import { DebitAccess1726496225078 } from './build/src/services/storage/migrations/1726496225078-debit_access.js'
@ -35,6 +48,7 @@ import { DebitToPub1727105758354 } from './build/src/services/storage/migrations
import { UserCbUrl1727112281043 } from './build/src/services/storage/migrations/1727112281043-user_cb_url.js' import { UserCbUrl1727112281043 } from './build/src/services/storage/migrations/1727112281043-user_cb_url.js'
import { UserOffer1733502626042 } from './build/src/services/storage/migrations/1733502626042-user_offer.js' import { UserOffer1733502626042 } from './build/src/services/storage/migrations/1733502626042-user_offer.js'
import { ManagementGrant1751307732346 } from './build/src/services/storage/migrations/1751307732346-management_grant.js' import { ManagementGrant1751307732346 } from './build/src/services/storage/migrations/1751307732346-management_grant.js'
import { ManagementGrantBanned1751989251513 } from './build/src/services/storage/migrations/1751989251513-management_grant_banned.js'
import { InvoiceCallbackUrls1752425992291 } from './build/src/services/storage/migrations/1752425992291-invoice_callback_urls.js' import { InvoiceCallbackUrls1752425992291 } from './build/src/services/storage/migrations/1752425992291-invoice_callback_urls.js'
import { OldSomethingLeftover1753106599604 } from './build/src/services/storage/migrations/1753106599604-old_something_leftover.js' import { OldSomethingLeftover1753106599604 } from './build/src/services/storage/migrations/1753106599604-old_something_leftover.js'
import { UserReceivingInvoiceIdx1753109184611 } from './build/src/services/storage/migrations/1753109184611-user_receiving_invoice_idx.js' import { UserReceivingInvoiceIdx1753109184611 } from './build/src/services/storage/migrations/1753109184611-user_receiving_invoice_idx.js'
@ -47,20 +61,32 @@ import { TxSwap1762890527098 } from './build/src/services/storage/migrations/176
import { TxSwapAddress1764779178945 } from './build/src/services/storage/migrations/1764779178945-tx_swap_address.js' import { TxSwapAddress1764779178945 } from './build/src/services/storage/migrations/1764779178945-tx_swap_address.js'
import { ClinkRequester1765497600000 } from './build/src/services/storage/migrations/1765497600000-clink_requester.js' import { ClinkRequester1765497600000 } from './build/src/services/storage/migrations/1765497600000-clink_requester.js'
import { TrackedProviderHeight1766504040000 } from './build/src/services/storage/migrations/1766504040000-tracked_provider_height.js' import { TrackedProviderHeight1766504040000 } from './build/src/services/storage/migrations/1766504040000-tracked_provider_height.js'
import { SwapsServiceUrl1768413055036 } from './build/src/services/storage/migrations/1768413055036-swaps_service_url.js'
import { InvoiceSwaps1769529793283 } from './build/src/services/storage/migrations/1769529793283-invoice_swaps.js'
import { InvoiceSwapsFixes1769805357459 } from './build/src/services/storage/migrations/1769805357459-invoice_swaps_fixes.js'
import { ApplicationUserTopicId1770038768784 } from './build/src/services/storage/migrations/1770038768784-application_user_topic_id.js'
import { SwapTimestamps1771347307798 } from './build/src/services/storage/migrations/1771347307798-swap_timestamps.js'
export default new DataSource({ export default new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
database: "db.sqlite", database: "db.sqlite",
// logging: true, // logging: true,
migrations: [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, CreateInviteTokenTable1721751414878, migrations: [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189,
PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264,
UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513,
AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175,
TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000], UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098,
TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036,
InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798
],
entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment,
UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo,
TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap], TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap, InvoiceSwap],
// synchronize: true, // synchronize: true,
}) })
//npx typeorm migration:generate ./src/services/storage/migrations/swaps_service_url -d ./datasource.js //npx typeorm migration:generate ./src/services/storage/migrations/tx_swap_timestamps -d ./datasource.js

View file

@ -1,3 +1,14 @@
/**
* TypeORM DataSource used only by the TypeORM CLI (e.g. migration:generate).
*
* Migrations at runtime are run from src/services/storage/migrations/runner.ts (allMigrations),
* not from this file. The app never uses this DataSource to run migrations.
*
* Workflow: update the migrations array in this file *before* running
* migration:generate, so TypeORM knows the current schema (entities + existing migrations).
* We do not update this file immediately after adding a new migration; update it when you
* are about to generate the next migration.
*/
import { DataSource } from "typeorm" import { DataSource } from "typeorm"
import { BalanceEvent } from "./build/src/services/storage/entity/BalanceEvent.js" import { BalanceEvent } from "./build/src/services/storage/entity/BalanceEvent.js"
import { ChannelBalanceEvent } from "./build/src/services/storage/entity/ChannelsBalanceEvent.js" import { ChannelBalanceEvent } from "./build/src/services/storage/entity/ChannelsBalanceEvent.js"
@ -8,12 +19,16 @@ import { LndMetrics1703170330183 } from './build/src/services/storage/migrations
import { ChannelRouting1709316653538 } from './build/src/services/storage/migrations/1709316653538-channel_routing.js' import { ChannelRouting1709316653538 } from './build/src/services/storage/migrations/1709316653538-channel_routing.js'
import { HtlcCount1724266887195 } from './build/src/services/storage/migrations/1724266887195-htlc_count.js' import { HtlcCount1724266887195 } from './build/src/services/storage/migrations/1724266887195-htlc_count.js'
import { BalanceEvents1724860966825 } from './build/src/services/storage/migrations/1724860966825-balance_events.js' import { BalanceEvents1724860966825 } from './build/src/services/storage/migrations/1724860966825-balance_events.js'
import { RootOps1732566440447 } from './build/src/services/storage/migrations/1732566440447-root_ops.js'
import { RootOpsTime1745428134124 } from './build/src/services/storage/migrations/1745428134124-root_ops_time.js'
import { ChannelEvents1750777346411 } from './build/src/services/storage/migrations/1750777346411-channel_events.js'
import { RootOpPending1771524665409 } from './build/src/services/storage/migrations/1771524665409-root_op_pending.js'
export default new DataSource({ export default new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
database: "metrics.sqlite", database: "metrics.sqlite",
entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting, RootOperation, ChannelEvent], entities: [BalanceEvent, ChannelBalanceEvent, ChannelRouting, RootOperation, ChannelEvent],
migrations: [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825] migrations: [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825,
RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411, RootOpPending1771524665409]
}); });
//npx typeorm migration:generate ./src/services/storage/migrations/channel_events -d ./metricsDatasource.js //npx typeorm migration:generate ./src/services/storage/migrations/root_op_pending -d ./metricsDatasource.js

2926
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@
"zip-a-folder": "^3.1.9" "zip-a-folder": "^3.1.9"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^4.3.4", "@types/chai": "^4.3.4",
"@types/chai-string": "^1.4.5", "@types/chai-string": "^1.4.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",

View file

@ -58,6 +58,11 @@ The nostr server will send back a message response, and inside the body there wi
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- BumpTx
- auth type: __Admin__
- input: [BumpTx](#BumpTx)
- This methods has an __empty__ __response__ body
- CloseChannel - CloseChannel
- auth type: __Admin__ - auth type: __Admin__
- input: [CloseChannelRequest](#CloseChannelRequest) - input: [CloseChannelRequest](#CloseChannelRequest)
@ -93,6 +98,11 @@ The nostr server will send back a message response, and inside the body there wi
- input: [MessagingToken](#MessagingToken) - input: [MessagingToken](#MessagingToken)
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- GetAdminInvoiceSwapQuotes
- auth type: __Admin__
- input: [InvoiceSwapRequest](#InvoiceSwapRequest)
- output: [InvoiceSwapQuoteList](#InvoiceSwapQuoteList)
- GetAdminTransactionSwapQuotes - GetAdminTransactionSwapQuotes
- auth type: __Admin__ - auth type: __Admin__
- input: [TransactionSwapRequest](#TransactionSwapRequest) - input: [TransactionSwapRequest](#TransactionSwapRequest)
@ -103,6 +113,11 @@ The nostr server will send back a message response, and inside the body there wi
- input: [AppsMetricsRequest](#AppsMetricsRequest) - input: [AppsMetricsRequest](#AppsMetricsRequest)
- output: [AppsMetrics](#AppsMetrics) - output: [AppsMetrics](#AppsMetrics)
- GetAssetsAndLiabilities
- auth type: __Admin__
- input: [AssetsAndLiabilitiesReq](#AssetsAndLiabilitiesReq)
- output: [AssetsAndLiabilities](#AssetsAndLiabilities)
- GetBundleMetrics - GetBundleMetrics
- auth type: __Metrics__ - auth type: __Metrics__
- input: [LatestBundleMetricReq](#LatestBundleMetricReq) - input: [LatestBundleMetricReq](#LatestBundleMetricReq)
@ -243,20 +258,25 @@ The nostr server will send back a message response, and inside the body there wi
- input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest) - input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest)
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- ListAdminSwaps - ListAdminInvoiceSwaps
- auth type: __Admin__ - auth type: __Admin__
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [SwapsList](#SwapsList) - output: [InvoiceSwapsList](#InvoiceSwapsList)
- ListAdminTxSwaps
- auth type: __Admin__
- This methods has an __empty__ __request__ body
- output: [TxSwapsList](#TxSwapsList)
- ListChannels - ListChannels
- auth type: __Admin__ - auth type: __Admin__
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [LndChannels](#LndChannels) - output: [LndChannels](#LndChannels)
- ListSwaps - ListTxSwaps
- auth type: __User__ - auth type: __User__
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [SwapsList](#SwapsList) - output: [TxSwapsList](#TxSwapsList)
- LndGetInfo - LndGetInfo
- auth type: __Admin__ - auth type: __Admin__
@ -290,10 +310,15 @@ The nostr server will send back a message response, and inside the body there wi
- input: [PayAddressRequest](#PayAddressRequest) - input: [PayAddressRequest](#PayAddressRequest)
- output: [PayAddressResponse](#PayAddressResponse) - output: [PayAddressResponse](#PayAddressResponse)
- PayAdminInvoiceSwap
- auth type: __Admin__
- input: [PayAdminInvoiceSwapRequest](#PayAdminInvoiceSwapRequest)
- output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse)
- PayAdminTransactionSwap - PayAdminTransactionSwap
- auth type: __Admin__ - auth type: __Admin__
- input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest) - input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest)
- output: [AdminSwapResponse](#AdminSwapResponse) - output: [AdminTxSwapResponse](#AdminTxSwapResponse)
- PayInvoice - PayInvoice
- auth type: __User__ - auth type: __User__
@ -305,6 +330,11 @@ The nostr server will send back a message response, and inside the body there wi
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- RefundAdminInvoiceSwap
- auth type: __Admin__
- input: [RefundAdminInvoiceSwapRequest](#RefundAdminInvoiceSwapRequest)
- output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse)
- ResetDebit - ResetDebit
- auth type: __User__ - auth type: __User__
- input: [DebitOperation](#DebitOperation) - input: [DebitOperation](#DebitOperation)
@ -484,6 +514,13 @@ The nostr server will send back a message response, and inside the body there wi
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- BumpTx
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/tx/bump__
- input: [BumpTx](#BumpTx)
- This methods has an __empty__ __response__ body
- CloseChannel - CloseChannel
- auth type: __Admin__ - auth type: __Admin__
- http method: __post__ - http method: __post__
@ -540,6 +577,13 @@ The nostr server will send back a message response, and inside the body there wi
- input: [MessagingToken](#MessagingToken) - input: [MessagingToken](#MessagingToken)
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- GetAdminInvoiceSwapQuotes
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/swap/invoice/quote__
- input: [InvoiceSwapRequest](#InvoiceSwapRequest)
- output: [InvoiceSwapQuoteList](#InvoiceSwapQuoteList)
- GetAdminTransactionSwapQuotes - GetAdminTransactionSwapQuotes
- auth type: __Admin__ - auth type: __Admin__
- http method: __post__ - http method: __post__
@ -575,6 +619,13 @@ The nostr server will send back a message response, and inside the body there wi
- input: [AppsMetricsRequest](#AppsMetricsRequest) - input: [AppsMetricsRequest](#AppsMetricsRequest)
- output: [AppsMetrics](#AppsMetrics) - output: [AppsMetrics](#AppsMetrics)
- GetAssetsAndLiabilities
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/assets/liabilities__
- input: [AssetsAndLiabilitiesReq](#AssetsAndLiabilitiesReq)
- output: [AssetsAndLiabilities](#AssetsAndLiabilities)
- GetBundleMetrics - GetBundleMetrics
- auth type: __Metrics__ - auth type: __Metrics__
- http method: __post__ - http method: __post__
@ -743,7 +794,7 @@ The nostr server will send back a message response, and inside the body there wi
- GetTransactionSwapQuotes - GetTransactionSwapQuotes
- auth type: __User__ - auth type: __User__
- http method: __post__ - http method: __post__
- http route: __/api/user/swap/quote__ - http route: __/api/user/swap/transaction/quote__
- input: [TransactionSwapRequest](#TransactionSwapRequest) - input: [TransactionSwapRequest](#TransactionSwapRequest)
- output: [TransactionSwapQuoteList](#TransactionSwapQuoteList) - output: [TransactionSwapQuoteList](#TransactionSwapQuoteList)
@ -834,12 +885,19 @@ The nostr server will send back a message response, and inside the body there wi
- input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest) - input: [LinkNPubThroughTokenRequest](#LinkNPubThroughTokenRequest)
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- ListAdminSwaps - ListAdminInvoiceSwaps
- auth type: __Admin__ - auth type: __Admin__
- http method: __post__ - http method: __post__
- http route: __/api/admin/swap/list__ - http route: __/api/admin/swap/invoice/list__
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [SwapsList](#SwapsList) - output: [InvoiceSwapsList](#InvoiceSwapsList)
- ListAdminTxSwaps
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/swap/transaction/list__
- This methods has an __empty__ __request__ body
- output: [TxSwapsList](#TxSwapsList)
- ListChannels - ListChannels
- auth type: __Admin__ - auth type: __Admin__
@ -848,12 +906,12 @@ The nostr server will send back a message response, and inside the body there wi
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [LndChannels](#LndChannels) - output: [LndChannels](#LndChannels)
- ListSwaps - ListTxSwaps
- auth type: __User__ - auth type: __User__
- http method: __post__ - http method: __post__
- http route: __/api/user/swap/list__ - http route: __/api/user/swap/transaction/list__
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- output: [SwapsList](#SwapsList) - output: [TxSwapsList](#TxSwapsList)
- LndGetInfo - LndGetInfo
- auth type: __Admin__ - auth type: __Admin__
@ -899,12 +957,19 @@ The nostr server will send back a message response, and inside the body there wi
- input: [PayAddressRequest](#PayAddressRequest) - input: [PayAddressRequest](#PayAddressRequest)
- output: [PayAddressResponse](#PayAddressResponse) - output: [PayAddressResponse](#PayAddressResponse)
- PayAdminInvoiceSwap
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/swap/invoice/pay__
- input: [PayAdminInvoiceSwapRequest](#PayAdminInvoiceSwapRequest)
- output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse)
- PayAdminTransactionSwap - PayAdminTransactionSwap
- auth type: __Admin__ - auth type: __Admin__
- http method: __post__ - http method: __post__
- http route: __/api/admin/swap/transaction/pay__ - http route: __/api/admin/swap/transaction/pay__
- input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest) - input: [PayAdminTransactionSwapRequest](#PayAdminTransactionSwapRequest)
- output: [AdminSwapResponse](#AdminSwapResponse) - output: [AdminTxSwapResponse](#AdminTxSwapResponse)
- PayAppUserInvoice - PayAppUserInvoice
- auth type: __App__ - auth type: __App__
@ -927,6 +992,13 @@ The nostr server will send back a message response, and inside the body there wi
- This methods has an __empty__ __request__ body - This methods has an __empty__ __request__ body
- This methods has an __empty__ __response__ body - This methods has an __empty__ __response__ body
- RefundAdminInvoiceSwap
- auth type: __Admin__
- http method: __post__
- http route: __/api/admin/swap/invoice/refund__
- input: [RefundAdminInvoiceSwapRequest](#RefundAdminInvoiceSwapRequest)
- output: [AdminInvoiceSwapResponse](#AdminInvoiceSwapResponse)
- RequestNPubLinkingToken - RequestNPubLinkingToken
- auth type: __App__ - auth type: __App__
- http method: __post__ - http method: __post__
@ -1098,7 +1170,10 @@ The nostr server will send back a message response, and inside the body there wi
- __name__: _string_ - __name__: _string_
- __price_sats__: _number_ - __price_sats__: _number_
### AdminSwapResponse ### AdminInvoiceSwapResponse
- __tx_id__: _string_
### AdminTxSwapResponse
- __network_fee__: _number_ - __network_fee__: _number_
- __tx_id__: _string_ - __tx_id__: _string_
@ -1135,6 +1210,21 @@ The nostr server will send back a message response, and inside the body there wi
- __include_operations__: _boolean_ *this field is optional - __include_operations__: _boolean_ *this field is optional
- __to_unix__: _number_ *this field is optional - __to_unix__: _number_ *this field is optional
### AssetOperation
- __amount__: _number_
- __tracked__: _[TrackedOperation](#TrackedOperation)_ *this field is optional
- __ts__: _number_
### AssetsAndLiabilities
- __liquidity_providers__: ARRAY of: _[LiquidityAssetProvider](#LiquidityAssetProvider)_
- __lnds__: ARRAY of: _[LndAssetProvider](#LndAssetProvider)_
- __users_balance__: _number_
### AssetsAndLiabilitiesReq
- __limit_invoices__: _number_ *this field is optional
- __limit_payments__: _number_ *this field is optional
- __limit_providers__: _number_ *this field is optional
### AuthApp ### AuthApp
- __app__: _[Application](#Application)_ - __app__: _[Application](#Application)_
- __auth_token__: _string_ - __auth_token__: _string_
@ -1163,6 +1253,11 @@ The nostr server will send back a message response, and inside the body there wi
- __nextRelay__: _string_ *this field is optional - __nextRelay__: _string_ *this field is optional
- __type__: _string_ - __type__: _string_
### BumpTx
- __output_index__: _number_
- __sat_per_vbyte__: _number_
- __txid__: _string_
### BundleData ### BundleData
- __available_chunks__: ARRAY of: _number_ - __available_chunks__: ARRAY of: _number_
- __base_64_data__: ARRAY of: _string_ - __base_64_data__: ARRAY of: _string_
@ -1331,6 +1426,36 @@ The nostr server will send back a message response, and inside the body there wi
- __token__: _string_ - __token__: _string_
- __url__: _string_ - __url__: _string_
### InvoiceSwapOperation
- __completed_at_unix__: _number_ *this field is optional
- __failure_reason__: _string_ *this field is optional
- __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional
- __quote__: _[InvoiceSwapQuote](#InvoiceSwapQuote)_
### InvoiceSwapQuote
- __address__: _string_
- __chain_fee_sats__: _number_
- __expires_at_block_height__: _number_
- __invoice__: _string_
- __invoice_amount_sats__: _number_
- __paid_at_unix__: _number_
- __service_fee_sats__: _number_
- __service_url__: _string_
- __swap_fee_sats__: _number_
- __swap_operation_id__: _string_
- __transaction_amount_sats__: _number_
- __tx_id__: _string_
### InvoiceSwapQuoteList
- __quotes__: ARRAY of: _[InvoiceSwapQuote](#InvoiceSwapQuote)_
### InvoiceSwapRequest
- __amount_sats__: _number_
### InvoiceSwapsList
- __current_block_height__: _number_
- __swaps__: ARRAY of: _[InvoiceSwapOperation](#InvoiceSwapOperation)_
### LatestBundleMetricReq ### LatestBundleMetricReq
- __limit__: _number_ *this field is optional - __limit__: _number_ *this field is optional
@ -1340,6 +1465,10 @@ The nostr server will send back a message response, and inside the body there wi
### LinkNPubThroughTokenRequest ### LinkNPubThroughTokenRequest
- __token__: _string_ - __token__: _string_
### LiquidityAssetProvider
- __pubkey__: _string_
- __tracked__: _[TrackedLiquidityProvider](#TrackedLiquidityProvider)_ *this field is optional
### LiveDebitRequest ### LiveDebitRequest
- __debit__: _[LiveDebitRequest_debit](#LiveDebitRequest_debit)_ - __debit__: _[LiveDebitRequest_debit](#LiveDebitRequest_debit)_
- __npub__: _string_ - __npub__: _string_
@ -1353,6 +1482,10 @@ The nostr server will send back a message response, and inside the body there wi
- __latest_balance__: _number_ - __latest_balance__: _number_
- __operation__: _[UserOperation](#UserOperation)_ - __operation__: _[UserOperation](#UserOperation)_
### LndAssetProvider
- __pubkey__: _string_
- __tracked__: _[TrackedLndProvider](#TrackedLndProvider)_ *this field is optional
### LndChannels ### LndChannels
- __open_channels__: ARRAY of: _[OpenChannel](#OpenChannel)_ - __open_channels__: ARRAY of: _[OpenChannel](#OpenChannel)_
@ -1534,6 +1667,11 @@ The nostr server will send back a message response, and inside the body there wi
- __service_fee__: _number_ - __service_fee__: _number_
- __txId__: _string_ - __txId__: _string_
### PayAdminInvoiceSwapRequest
- __no_claim__: _boolean_ *this field is optional
- __sat_per_v_byte__: _number_
- __swap_operation_id__: _string_
### PayAdminTransactionSwapRequest ### PayAdminTransactionSwapRequest
- __address__: _string_ - __address__: _string_
- __swap_operation_id__: _string_ - __swap_operation_id__: _string_
@ -1584,6 +1722,18 @@ The nostr server will send back a message response, and inside the body there wi
### ProvidersDisruption ### ProvidersDisruption
- __disruptions__: ARRAY of: _[ProviderDisruption](#ProviderDisruption)_ - __disruptions__: ARRAY of: _[ProviderDisruption](#ProviderDisruption)_
### PushNotificationEnvelope
- __app_npub_hex__: _string_
- __encrypted_payload__: _string_
- __topic_id__: _string_
### PushNotificationPayload
- __data__: _[PushNotificationPayload_data](#PushNotificationPayload_data)_
### RefundAdminInvoiceSwapRequest
- __sat_per_v_byte__: _number_
- __swap_operation_id__: _string_
### RelaysMigration ### RelaysMigration
- __relays__: ARRAY of: _string_ - __relays__: ARRAY of: _string_
@ -1640,19 +1790,31 @@ The nostr server will send back a message response, and inside the body there wi
- __page__: _number_ - __page__: _number_
- __request_id__: _number_ *this field is optional - __request_id__: _number_ *this field is optional
### SwapOperation ### TrackedLiquidityProvider
- __address_paid__: _string_ - __balance__: _number_
- __failure_reason__: _string_ *this field is optional - __invoices__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional - __payments__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __swap_operation_id__: _string_
### SwapsList ### TrackedLndProvider
- __quotes__: ARRAY of: _[TransactionSwapQuote](#TransactionSwapQuote)_ - __channels_balance__: _number_
- __swaps__: ARRAY of: _[SwapOperation](#SwapOperation)_ - __confirmed_balance__: _number_
- __incoming_tx__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __invoices__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __outgoing_tx__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __payments__: ARRAY of: _[AssetOperation](#AssetOperation)_
- __unconfirmed_balance__: _number_
### TrackedOperation
- __amount__: _number_
- __ts__: _number_
- __type__: _[TrackedOperationType](#TrackedOperationType)_
### TransactionSwapQuote ### TransactionSwapQuote
- __chain_fee_sats__: _number_ - __chain_fee_sats__: _number_
- __completed_at_unix__: _number_
- __expires_at_block_height__: _number_
- __invoice_amount_sats__: _number_ - __invoice_amount_sats__: _number_
- __paid_at_unix__: _number_
- __service_fee_sats__: _number_ - __service_fee_sats__: _number_
- __service_url__: _string_ - __service_url__: _string_
- __swap_fee_sats__: _number_ - __swap_fee_sats__: _number_
@ -1665,6 +1827,16 @@ The nostr server will send back a message response, and inside the body there wi
### TransactionSwapRequest ### TransactionSwapRequest
- __transaction_amount_sats__: _number_ - __transaction_amount_sats__: _number_
### TxSwapOperation
- __address_paid__: _string_ *this field is optional
- __failure_reason__: _string_ *this field is optional
- __operation_payment__: _[UserOperation](#UserOperation)_ *this field is optional
- __quote__: _[TransactionSwapQuote](#TransactionSwapQuote)_
- __tx_id__: _string_ *this field is optional
### TxSwapsList
- __swaps__: ARRAY of: _[TxSwapOperation](#TxSwapOperation)_
### UpdateChannelPolicyRequest ### UpdateChannelPolicyRequest
- __policy__: _[ChannelPolicy](#ChannelPolicy)_ - __policy__: _[ChannelPolicy](#ChannelPolicy)_
- __update__: _[UpdateChannelPolicyRequest_update](#UpdateChannelPolicyRequest_update)_ - __update__: _[UpdateChannelPolicyRequest_update](#UpdateChannelPolicyRequest_update)_
@ -1707,6 +1879,7 @@ The nostr server will send back a message response, and inside the body there wi
- __nmanage__: _string_ - __nmanage__: _string_
- __noffer__: _string_ - __noffer__: _string_
- __service_fee_bps__: _number_ - __service_fee_bps__: _number_
- __topic_id__: _string_
- __userId__: _string_ - __userId__: _string_
- __user_identifier__: _string_ - __user_identifier__: _string_
@ -1771,6 +1944,10 @@ The nostr server will send back a message response, and inside the body there wi
- __BUNDLE_METRIC__ - __BUNDLE_METRIC__
- __USAGE_METRIC__ - __USAGE_METRIC__
### TrackedOperationType
- __ROOT__
- __USER__
### UserOperationType ### UserOperationType
- __INCOMING_INVOICE__ - __INCOMING_INVOICE__
- __INCOMING_TX__ - __INCOMING_TX__

View file

@ -66,6 +66,7 @@ type Client struct {
BanDebit func(req DebitOperation) error BanDebit func(req DebitOperation) error
BanUser func(req BanUserRequest) (*BanUserResponse, error) BanUser func(req BanUserRequest) (*BanUserResponse, error)
// batching method: BatchUser not implemented // batching method: BatchUser not implemented
BumpTx func(req BumpTx) error
CloseChannel func(req CloseChannelRequest) (*CloseChannelResponse, error) CloseChannel func(req CloseChannelRequest) (*CloseChannelResponse, error)
CreateOneTimeInviteLink func(req CreateOneTimeInviteLinkRequest) (*CreateOneTimeInviteLinkResponse, error) CreateOneTimeInviteLink func(req CreateOneTimeInviteLinkRequest) (*CreateOneTimeInviteLinkResponse, error)
DecodeInvoice func(req DecodeInvoiceRequest) (*DecodeInvoiceResponse, error) DecodeInvoice func(req DecodeInvoiceRequest) (*DecodeInvoiceResponse, error)
@ -74,11 +75,13 @@ type Client struct {
EncryptionExchange func(req EncryptionExchangeRequest) error EncryptionExchange func(req EncryptionExchangeRequest) error
EnrollAdminToken func(req EnrollAdminTokenRequest) error EnrollAdminToken func(req EnrollAdminTokenRequest) error
EnrollMessagingToken func(req MessagingToken) error EnrollMessagingToken func(req MessagingToken) error
GetAdminInvoiceSwapQuotes func(req InvoiceSwapRequest) (*InvoiceSwapQuoteList, error)
GetAdminTransactionSwapQuotes func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error) GetAdminTransactionSwapQuotes func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error)
GetApp func() (*Application, error) GetApp func() (*Application, error)
GetAppUser func(req GetAppUserRequest) (*AppUser, error) GetAppUser func(req GetAppUserRequest) (*AppUser, error)
GetAppUserLNURLInfo func(req GetAppUserLNURLInfoRequest) (*LnurlPayInfoResponse, error) GetAppUserLNURLInfo func(req GetAppUserLNURLInfoRequest) (*LnurlPayInfoResponse, error)
GetAppsMetrics func(req AppsMetricsRequest) (*AppsMetrics, error) GetAppsMetrics func(req AppsMetricsRequest) (*AppsMetrics, error)
GetAssetsAndLiabilities func(req AssetsAndLiabilitiesReq) (*AssetsAndLiabilities, error)
GetBundleMetrics func(req LatestBundleMetricReq) (*BundleMetrics, error) GetBundleMetrics func(req LatestBundleMetricReq) (*BundleMetrics, error)
GetDebitAuthorizations func() (*DebitAuthorizations, error) GetDebitAuthorizations func() (*DebitAuthorizations, error)
GetErrorStats func() (*ErrorStats, error) GetErrorStats func() (*ErrorStats, error)
@ -114,19 +117,22 @@ type Client struct {
HandleLnurlWithdraw func(query HandleLnurlWithdraw_Query) error HandleLnurlWithdraw func(query HandleLnurlWithdraw_Query) error
Health func() error Health func() error
LinkNPubThroughToken func(req LinkNPubThroughTokenRequest) error LinkNPubThroughToken func(req LinkNPubThroughTokenRequest) error
ListAdminSwaps func() (*SwapsList, error) ListAdminInvoiceSwaps func() (*InvoiceSwapsList, error)
ListAdminTxSwaps func() (*TxSwapsList, error)
ListChannels func() (*LndChannels, error) ListChannels func() (*LndChannels, error)
ListSwaps func() (*SwapsList, error) ListTxSwaps func() (*TxSwapsList, error)
LndGetInfo func(req LndGetInfoRequest) (*LndGetInfoResponse, error) LndGetInfo func(req LndGetInfoRequest) (*LndGetInfoResponse, error)
NewAddress func(req NewAddressRequest) (*NewAddressResponse, error) NewAddress func(req NewAddressRequest) (*NewAddressResponse, error)
NewInvoice func(req NewInvoiceRequest) (*NewInvoiceResponse, error) NewInvoice func(req NewInvoiceRequest) (*NewInvoiceResponse, error)
NewProductInvoice func(query NewProductInvoice_Query) (*NewInvoiceResponse, error) NewProductInvoice func(query NewProductInvoice_Query) (*NewInvoiceResponse, error)
OpenChannel func(req OpenChannelRequest) (*OpenChannelResponse, error) OpenChannel func(req OpenChannelRequest) (*OpenChannelResponse, error)
PayAddress func(req PayAddressRequest) (*PayAddressResponse, error) PayAddress func(req PayAddressRequest) (*PayAddressResponse, error)
PayAdminTransactionSwap func(req PayAdminTransactionSwapRequest) (*AdminSwapResponse, error) PayAdminInvoiceSwap func(req PayAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error)
PayAdminTransactionSwap func(req PayAdminTransactionSwapRequest) (*AdminTxSwapResponse, error)
PayAppUserInvoice func(req PayAppUserInvoiceRequest) (*PayInvoiceResponse, error) PayAppUserInvoice func(req PayAppUserInvoiceRequest) (*PayInvoiceResponse, error)
PayInvoice func(req PayInvoiceRequest) (*PayInvoiceResponse, error) PayInvoice func(req PayInvoiceRequest) (*PayInvoiceResponse, error)
PingSubProcesses func() error PingSubProcesses func() error
RefundAdminInvoiceSwap func(req RefundAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error)
RequestNPubLinkingToken func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) RequestNPubLinkingToken func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error)
ResetDebit func(req DebitOperation) error ResetDebit func(req DebitOperation) error
ResetManage func(req ManageOperation) error ResetManage func(req ManageOperation) error
@ -460,6 +466,30 @@ func NewClient(params ClientParams) *Client {
return &res, nil return &res, nil
}, },
// batching method: BatchUser not implemented // batching method: BatchUser not implemented
BumpTx: func(req BumpTx) error {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return err
}
finalRoute := "/api/admin/tx/bump"
body, err := json.Marshal(req)
if err != nil {
return err
}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return err
}
if result.Status == "ERROR" {
return fmt.Errorf(result.Reason)
}
return nil
},
CloseChannel: func(req CloseChannelRequest) (*CloseChannelResponse, error) { CloseChannel: func(req CloseChannelRequest) (*CloseChannelResponse, error) {
auth, err := params.RetrieveAdminAuth() auth, err := params.RetrieveAdminAuth()
if err != nil { if err != nil {
@ -667,6 +697,35 @@ func NewClient(params ClientParams) *Client {
} }
return nil return nil
}, },
GetAdminInvoiceSwapQuotes: func(req InvoiceSwapRequest) (*InvoiceSwapQuoteList, error) {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return nil, err
}
finalRoute := "/api/admin/swap/invoice/quote"
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return nil, err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return nil, err
}
if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason)
}
res := InvoiceSwapQuoteList{}
err = json.Unmarshal(resBody, &res)
if err != nil {
return nil, err
}
return &res, nil
},
GetAdminTransactionSwapQuotes: func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error) { GetAdminTransactionSwapQuotes: func(req TransactionSwapRequest) (*TransactionSwapQuoteList, error) {
auth, err := params.RetrieveAdminAuth() auth, err := params.RetrieveAdminAuth()
if err != nil { if err != nil {
@ -809,6 +868,35 @@ func NewClient(params ClientParams) *Client {
} }
return &res, nil return &res, nil
}, },
GetAssetsAndLiabilities: func(req AssetsAndLiabilitiesReq) (*AssetsAndLiabilities, error) {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return nil, err
}
finalRoute := "/api/admin/assets/liabilities"
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return nil, err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return nil, err
}
if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason)
}
res := AssetsAndLiabilities{}
err = json.Unmarshal(resBody, &res)
if err != nil {
return nil, err
}
return &res, nil
},
GetBundleMetrics: func(req LatestBundleMetricReq) (*BundleMetrics, error) { GetBundleMetrics: func(req LatestBundleMetricReq) (*BundleMetrics, error) {
auth, err := params.RetrieveMetricsAuth() auth, err := params.RetrieveMetricsAuth()
if err != nil { if err != nil {
@ -1324,7 +1412,7 @@ func NewClient(params ClientParams) *Client {
if err != nil { if err != nil {
return nil, err return nil, err
} }
finalRoute := "/api/user/swap/quote" finalRoute := "/api/user/swap/transaction/quote"
body, err := json.Marshal(req) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1643,12 +1731,12 @@ func NewClient(params ClientParams) *Client {
} }
return nil return nil
}, },
ListAdminSwaps: func() (*SwapsList, error) { ListAdminInvoiceSwaps: func() (*InvoiceSwapsList, error) {
auth, err := params.RetrieveAdminAuth() auth, err := params.RetrieveAdminAuth()
if err != nil { if err != nil {
return nil, err return nil, err
} }
finalRoute := "/api/admin/swap/list" finalRoute := "/api/admin/swap/invoice/list"
body := []byte{} body := []byte{}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil { if err != nil {
@ -1662,7 +1750,33 @@ func NewClient(params ClientParams) *Client {
if result.Status == "ERROR" { if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason) return nil, fmt.Errorf(result.Reason)
} }
res := SwapsList{} res := InvoiceSwapsList{}
err = json.Unmarshal(resBody, &res)
if err != nil {
return nil, err
}
return &res, nil
},
ListAdminTxSwaps: func() (*TxSwapsList, error) {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return nil, err
}
finalRoute := "/api/admin/swap/transaction/list"
body := []byte{}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return nil, err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return nil, err
}
if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason)
}
res := TxSwapsList{}
err = json.Unmarshal(resBody, &res) err = json.Unmarshal(resBody, &res)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1691,12 +1805,12 @@ func NewClient(params ClientParams) *Client {
} }
return &res, nil return &res, nil
}, },
ListSwaps: func() (*SwapsList, error) { ListTxSwaps: func() (*TxSwapsList, error) {
auth, err := params.RetrieveUserAuth() auth, err := params.RetrieveUserAuth()
if err != nil { if err != nil {
return nil, err return nil, err
} }
finalRoute := "/api/user/swap/list" finalRoute := "/api/user/swap/transaction/list"
body := []byte{} body := []byte{}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil { if err != nil {
@ -1710,7 +1824,7 @@ func NewClient(params ClientParams) *Client {
if result.Status == "ERROR" { if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason) return nil, fmt.Errorf(result.Reason)
} }
res := SwapsList{} res := TxSwapsList{}
err = json.Unmarshal(resBody, &res) err = json.Unmarshal(resBody, &res)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1892,7 +2006,36 @@ func NewClient(params ClientParams) *Client {
} }
return &res, nil return &res, nil
}, },
PayAdminTransactionSwap: func(req PayAdminTransactionSwapRequest) (*AdminSwapResponse, error) { PayAdminInvoiceSwap: func(req PayAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return nil, err
}
finalRoute := "/api/admin/swap/invoice/pay"
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return nil, err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return nil, err
}
if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason)
}
res := AdminInvoiceSwapResponse{}
err = json.Unmarshal(resBody, &res)
if err != nil {
return nil, err
}
return &res, nil
},
PayAdminTransactionSwap: func(req PayAdminTransactionSwapRequest) (*AdminTxSwapResponse, error) {
auth, err := params.RetrieveAdminAuth() auth, err := params.RetrieveAdminAuth()
if err != nil { if err != nil {
return nil, err return nil, err
@ -1914,7 +2057,7 @@ func NewClient(params ClientParams) *Client {
if result.Status == "ERROR" { if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason) return nil, fmt.Errorf(result.Reason)
} }
res := AdminSwapResponse{} res := AdminTxSwapResponse{}
err = json.Unmarshal(resBody, &res) err = json.Unmarshal(resBody, &res)
if err != nil { if err != nil {
return nil, err return nil, err
@ -2000,6 +2143,35 @@ func NewClient(params ClientParams) *Client {
} }
return nil return nil
}, },
RefundAdminInvoiceSwap: func(req RefundAdminInvoiceSwapRequest) (*AdminInvoiceSwapResponse, error) {
auth, err := params.RetrieveAdminAuth()
if err != nil {
return nil, err
}
finalRoute := "/api/admin/swap/invoice/refund"
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth)
if err != nil {
return nil, err
}
result := ResultError{}
err = json.Unmarshal(resBody, &result)
if err != nil {
return nil, err
}
if result.Status == "ERROR" {
return nil, fmt.Errorf(result.Reason)
}
res := AdminInvoiceSwapResponse{}
err = json.Unmarshal(resBody, &res)
if err != nil {
return nil, err
}
return &res, nil
},
RequestNPubLinkingToken: func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) { RequestNPubLinkingToken: func(req RequestNPubLinkingTokenRequest) (*RequestNPubLinkingTokenResponse, error) {
auth, err := params.RetrieveAppAuth() auth, err := params.RetrieveAppAuth()
if err != nil { if err != nil {

View file

@ -79,6 +79,13 @@ const (
USAGE_METRIC SingleMetricType = "USAGE_METRIC" USAGE_METRIC SingleMetricType = "USAGE_METRIC"
) )
type TrackedOperationType string
const (
ROOT TrackedOperationType = "ROOT"
USER TrackedOperationType = "USER"
)
type UserOperationType string type UserOperationType string
const ( const (
@ -123,7 +130,10 @@ type AddProductRequest struct {
Name string `json:"name"` Name string `json:"name"`
Price_sats int64 `json:"price_sats"` Price_sats int64 `json:"price_sats"`
} }
type AdminSwapResponse struct { type AdminInvoiceSwapResponse struct {
Tx_id string `json:"tx_id"`
}
type AdminTxSwapResponse struct {
Network_fee int64 `json:"network_fee"` Network_fee int64 `json:"network_fee"`
Tx_id string `json:"tx_id"` Tx_id string `json:"tx_id"`
} }
@ -160,6 +170,21 @@ type AppsMetricsRequest struct {
Include_operations bool `json:"include_operations"` Include_operations bool `json:"include_operations"`
To_unix int64 `json:"to_unix"` To_unix int64 `json:"to_unix"`
} }
type AssetOperation struct {
Amount int64 `json:"amount"`
Tracked *TrackedOperation `json:"tracked"`
Ts int64 `json:"ts"`
}
type AssetsAndLiabilities struct {
Liquidity_providers []LiquidityAssetProvider `json:"liquidity_providers"`
Lnds []LndAssetProvider `json:"lnds"`
Users_balance int64 `json:"users_balance"`
}
type AssetsAndLiabilitiesReq struct {
Limit_invoices int64 `json:"limit_invoices"`
Limit_payments int64 `json:"limit_payments"`
Limit_providers int64 `json:"limit_providers"`
}
type AuthApp struct { type AuthApp struct {
App *Application `json:"app"` App *Application `json:"app"`
Auth_token string `json:"auth_token"` Auth_token string `json:"auth_token"`
@ -188,6 +213,11 @@ type BeaconData struct {
Nextrelay string `json:"nextRelay"` Nextrelay string `json:"nextRelay"`
Type string `json:"type"` Type string `json:"type"`
} }
type BumpTx struct {
Output_index int64 `json:"output_index"`
Sat_per_vbyte int64 `json:"sat_per_vbyte"`
Txid string `json:"txid"`
}
type BundleData struct { type BundleData struct {
Available_chunks []int64 `json:"available_chunks"` Available_chunks []int64 `json:"available_chunks"`
Base_64_data []string `json:"base_64_data"` Base_64_data []string `json:"base_64_data"`
@ -356,6 +386,36 @@ type HttpCreds struct {
Token string `json:"token"` Token string `json:"token"`
Url string `json:"url"` Url string `json:"url"`
} }
type InvoiceSwapOperation struct {
Completed_at_unix int64 `json:"completed_at_unix"`
Failure_reason string `json:"failure_reason"`
Operation_payment *UserOperation `json:"operation_payment"`
Quote *InvoiceSwapQuote `json:"quote"`
}
type InvoiceSwapQuote struct {
Address string `json:"address"`
Chain_fee_sats int64 `json:"chain_fee_sats"`
Expires_at_block_height int64 `json:"expires_at_block_height"`
Invoice string `json:"invoice"`
Invoice_amount_sats int64 `json:"invoice_amount_sats"`
Paid_at_unix int64 `json:"paid_at_unix"`
Service_fee_sats int64 `json:"service_fee_sats"`
Service_url string `json:"service_url"`
Swap_fee_sats int64 `json:"swap_fee_sats"`
Swap_operation_id string `json:"swap_operation_id"`
Transaction_amount_sats int64 `json:"transaction_amount_sats"`
Tx_id string `json:"tx_id"`
}
type InvoiceSwapQuoteList struct {
Quotes []InvoiceSwapQuote `json:"quotes"`
}
type InvoiceSwapRequest struct {
Amount_sats int64 `json:"amount_sats"`
}
type InvoiceSwapsList struct {
Current_block_height int64 `json:"current_block_height"`
Swaps []InvoiceSwapOperation `json:"swaps"`
}
type LatestBundleMetricReq struct { type LatestBundleMetricReq struct {
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
} }
@ -365,6 +425,10 @@ type LatestUsageMetricReq struct {
type LinkNPubThroughTokenRequest struct { type LinkNPubThroughTokenRequest struct {
Token string `json:"token"` Token string `json:"token"`
} }
type LiquidityAssetProvider struct {
Pubkey string `json:"pubkey"`
Tracked *TrackedLiquidityProvider `json:"tracked"`
}
type LiveDebitRequest struct { type LiveDebitRequest struct {
Debit *LiveDebitRequest_debit `json:"debit"` Debit *LiveDebitRequest_debit `json:"debit"`
Npub string `json:"npub"` Npub string `json:"npub"`
@ -378,6 +442,10 @@ type LiveUserOperation struct {
Latest_balance int64 `json:"latest_balance"` Latest_balance int64 `json:"latest_balance"`
Operation *UserOperation `json:"operation"` Operation *UserOperation `json:"operation"`
} }
type LndAssetProvider struct {
Pubkey string `json:"pubkey"`
Tracked *TrackedLndProvider `json:"tracked"`
}
type LndChannels struct { type LndChannels struct {
Open_channels []OpenChannel `json:"open_channels"` Open_channels []OpenChannel `json:"open_channels"`
} }
@ -559,6 +627,11 @@ type PayAddressResponse struct {
Service_fee int64 `json:"service_fee"` Service_fee int64 `json:"service_fee"`
Txid string `json:"txId"` Txid string `json:"txId"`
} }
type PayAdminInvoiceSwapRequest struct {
No_claim bool `json:"no_claim"`
Sat_per_v_byte int64 `json:"sat_per_v_byte"`
Swap_operation_id string `json:"swap_operation_id"`
}
type PayAdminTransactionSwapRequest struct { type PayAdminTransactionSwapRequest struct {
Address string `json:"address"` Address string `json:"address"`
Swap_operation_id string `json:"swap_operation_id"` Swap_operation_id string `json:"swap_operation_id"`
@ -609,6 +682,18 @@ type ProviderDisruption struct {
type ProvidersDisruption struct { type ProvidersDisruption struct {
Disruptions []ProviderDisruption `json:"disruptions"` Disruptions []ProviderDisruption `json:"disruptions"`
} }
type PushNotificationEnvelope struct {
App_npub_hex string `json:"app_npub_hex"`
Encrypted_payload string `json:"encrypted_payload"`
Topic_id string `json:"topic_id"`
}
type PushNotificationPayload struct {
Data *PushNotificationPayload_data `json:"data"`
}
type RefundAdminInvoiceSwapRequest struct {
Sat_per_v_byte int64 `json:"sat_per_v_byte"`
Swap_operation_id string `json:"swap_operation_id"`
}
type RelaysMigration struct { type RelaysMigration struct {
Relays []string `json:"relays"` Relays []string `json:"relays"`
} }
@ -665,19 +750,31 @@ type SingleMetricReq struct {
Page int64 `json:"page"` Page int64 `json:"page"`
Request_id int64 `json:"request_id"` Request_id int64 `json:"request_id"`
} }
type SwapOperation struct { type TrackedLiquidityProvider struct {
Address_paid string `json:"address_paid"` Balance int64 `json:"balance"`
Failure_reason string `json:"failure_reason"` Invoices []AssetOperation `json:"invoices"`
Operation_payment *UserOperation `json:"operation_payment"` Payments []AssetOperation `json:"payments"`
Swap_operation_id string `json:"swap_operation_id"`
} }
type SwapsList struct { type TrackedLndProvider struct {
Quotes []TransactionSwapQuote `json:"quotes"` Channels_balance int64 `json:"channels_balance"`
Swaps []SwapOperation `json:"swaps"` Confirmed_balance int64 `json:"confirmed_balance"`
Incoming_tx []AssetOperation `json:"incoming_tx"`
Invoices []AssetOperation `json:"invoices"`
Outgoing_tx []AssetOperation `json:"outgoing_tx"`
Payments []AssetOperation `json:"payments"`
Unconfirmed_balance int64 `json:"unconfirmed_balance"`
}
type TrackedOperation struct {
Amount int64 `json:"amount"`
Ts int64 `json:"ts"`
Type TrackedOperationType `json:"type"`
} }
type TransactionSwapQuote struct { type TransactionSwapQuote struct {
Chain_fee_sats int64 `json:"chain_fee_sats"` Chain_fee_sats int64 `json:"chain_fee_sats"`
Completed_at_unix int64 `json:"completed_at_unix"`
Expires_at_block_height int64 `json:"expires_at_block_height"`
Invoice_amount_sats int64 `json:"invoice_amount_sats"` Invoice_amount_sats int64 `json:"invoice_amount_sats"`
Paid_at_unix int64 `json:"paid_at_unix"`
Service_fee_sats int64 `json:"service_fee_sats"` Service_fee_sats int64 `json:"service_fee_sats"`
Service_url string `json:"service_url"` Service_url string `json:"service_url"`
Swap_fee_sats int64 `json:"swap_fee_sats"` Swap_fee_sats int64 `json:"swap_fee_sats"`
@ -690,6 +787,16 @@ type TransactionSwapQuoteList struct {
type TransactionSwapRequest struct { type TransactionSwapRequest struct {
Transaction_amount_sats int64 `json:"transaction_amount_sats"` Transaction_amount_sats int64 `json:"transaction_amount_sats"`
} }
type TxSwapOperation struct {
Address_paid string `json:"address_paid"`
Failure_reason string `json:"failure_reason"`
Operation_payment *UserOperation `json:"operation_payment"`
Quote *TransactionSwapQuote `json:"quote"`
Tx_id string `json:"tx_id"`
}
type TxSwapsList struct {
Swaps []TxSwapOperation `json:"swaps"`
}
type UpdateChannelPolicyRequest struct { type UpdateChannelPolicyRequest struct {
Policy *ChannelPolicy `json:"policy"` Policy *ChannelPolicy `json:"policy"`
Update *UpdateChannelPolicyRequest_update `json:"update"` Update *UpdateChannelPolicyRequest_update `json:"update"`
@ -732,6 +839,7 @@ type UserInfo struct {
Nmanage string `json:"nmanage"` Nmanage string `json:"nmanage"`
Noffer string `json:"noffer"` Noffer string `json:"noffer"`
Service_fee_bps int64 `json:"service_fee_bps"` Service_fee_bps int64 `json:"service_fee_bps"`
Topic_id string `json:"topic_id"`
Userid string `json:"userId"` Userid string `json:"userId"`
User_identifier string `json:"user_identifier"` User_identifier string `json:"user_identifier"`
} }
@ -830,6 +938,18 @@ type NPubLinking_state struct {
Linking_token *string `json:"linking_token"` Linking_token *string `json:"linking_token"`
Unlinked *Empty `json:"unlinked"` Unlinked *Empty `json:"unlinked"`
} }
type PushNotificationPayload_data_type string
const (
RECEIVED_OPERATION PushNotificationPayload_data_type = "received_operation"
SENT_OPERATION PushNotificationPayload_data_type = "sent_operation"
)
type PushNotificationPayload_data struct {
Type PushNotificationPayload_data_type `json:"type"`
Received_operation *UserOperation `json:"received_operation"`
Sent_operation *UserOperation `json:"sent_operation"`
}
type UpdateChannelPolicyRequest_update_type string type UpdateChannelPolicyRequest_update_type string
const ( const (

View file

@ -545,12 +545,12 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) callsMetrics.push({ ...opInfo, ...opStats, ...ctx })
} }
break break
case 'ListSwaps': case 'ListTxSwaps':
if (!methods.ListSwaps) { if (!methods.ListTxSwaps) {
throw new Error('method ListSwaps not found' ) throw new Error('method ListTxSwaps not found' )
} else { } else {
opStats.validate = opStats.guard opStats.validate = opStats.guard
const res = await methods.ListSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) const res = await methods.ListTxSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res })
opStats.handle = process.hrtime.bigint() opStats.handle = process.hrtime.bigint()
callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) callsMetrics.push({ ...opInfo, ...opStats, ...ctx })
} }
@ -693,6 +693,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics]) opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.BumpTx) throw new Error('method: BumpTx is not implemented')
app.post('/api/admin/tx/bump', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'BumpTx', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.BumpTx) throw new Error('method: BumpTx is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
const request = req.body
const error = Types.BumpTxValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)
const query = req.query
const params = req.params
await methods.BumpTx({rpcName:'BumpTx', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res.json({status: 'OK'})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.CloseChannel) throw new Error('method: CloseChannel is not implemented') if (!opts.allowNotImplementedMethods && !methods.CloseChannel) throw new Error('method: CloseChannel is not implemented')
app.post('/api/admin/channel/close', async (req, res) => { app.post('/api/admin/channel/close', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'CloseChannel', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'CloseChannel', batch: false, nostr: false, batchSize: 0}
@ -869,6 +891,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented')
app.post('/api/admin/swap/invoice/quote', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'GetAdminInvoiceSwapQuotes', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
const request = req.body
const error = Types.InvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)
const query = req.query
const params = req.params
const response = await methods.GetAdminInvoiceSwapQuotes({rpcName:'GetAdminInvoiceSwapQuotes', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented') if (!opts.allowNotImplementedMethods && !methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented')
app.post('/api/admin/swap/transaction/quote', async (req, res) => { app.post('/api/admin/swap/transaction/quote', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'GetAdminTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'GetAdminTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0}
@ -976,6 +1020,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented')
app.post('/api/admin/assets/liabilities', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'GetAssetsAndLiabilities', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
const request = req.body
const error = Types.AssetsAndLiabilitiesReqValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)
const query = req.query
const params = req.params
const response = await methods.GetAssetsAndLiabilities({rpcName:'GetAssetsAndLiabilities', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented') if (!opts.allowNotImplementedMethods && !methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented')
app.post('/api/reports/bundle', async (req, res) => { app.post('/api/reports/bundle', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'GetBundleMetrics', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'GetBundleMetrics', batch: false, nostr: false, batchSize: 0}
@ -1362,7 +1428,7 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.GetTransactionSwapQuotes) throw new Error('method: GetTransactionSwapQuotes is not implemented') if (!opts.allowNotImplementedMethods && !methods.GetTransactionSwapQuotes) throw new Error('method: GetTransactionSwapQuotes is not implemented')
app.post('/api/user/swap/quote', async (req, res) => { app.post('/api/user/swap/transaction/quote', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'GetTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'GetTransactionSwapQuotes', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {} let authCtx: Types.AuthContext = {}
@ -1607,20 +1673,39 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') if (!opts.allowNotImplementedMethods && !methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented')
app.post('/api/admin/swap/list', async (req, res) => { app.post('/api/admin/swap/invoice/list', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'ListAdminSwaps', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'ListAdminInvoiceSwaps', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {} let authCtx: Types.AuthContext = {}
try { try {
if (!methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') if (!methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization']) const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext authCtx = authContext
stats.guard = process.hrtime.bigint() stats.guard = process.hrtime.bigint()
stats.validate = stats.guard stats.validate = stats.guard
const query = req.query const query = req.query
const params = req.params const params = req.params
const response = await methods.ListAdminSwaps({rpcName:'ListAdminSwaps', ctx:authContext }) const response = await methods.ListAdminInvoiceSwaps({rpcName:'ListAdminInvoiceSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented')
app.post('/api/admin/swap/transaction/list', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'ListAdminTxSwaps', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
stats.validate = stats.guard
const query = req.query
const params = req.params
const response = await methods.ListAdminTxSwaps({rpcName:'ListAdminTxSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint() stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response}) res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
@ -1645,20 +1730,20 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') if (!opts.allowNotImplementedMethods && !methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented')
app.post('/api/user/swap/list', async (req, res) => { app.post('/api/user/swap/transaction/list', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'ListSwaps', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'ListTxSwaps', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {} let authCtx: Types.AuthContext = {}
try { try {
if (!methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') if (!methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented')
const authContext = await opts.UserAuthGuard(req.headers['authorization']) const authContext = await opts.UserAuthGuard(req.headers['authorization'])
authCtx = authContext authCtx = authContext
stats.guard = process.hrtime.bigint() stats.guard = process.hrtime.bigint()
stats.validate = stats.guard stats.validate = stats.guard
const query = req.query const query = req.query
const params = req.params const params = req.params
const response = await methods.ListSwaps({rpcName:'ListSwaps', ctx:authContext }) const response = await methods.ListTxSwaps({rpcName:'ListTxSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint() stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response}) res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
@ -1793,6 +1878,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented')
app.post('/api/admin/swap/invoice/pay', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'PayAdminInvoiceSwap', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
const request = req.body
const error = Types.PayAdminInvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)
const query = req.query
const params = req.params
const response = await methods.PayAdminInvoiceSwap({rpcName:'PayAdminInvoiceSwap', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented') if (!opts.allowNotImplementedMethods && !methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented')
app.post('/api/admin/swap/transaction/pay', async (req, res) => { app.post('/api/admin/swap/transaction/pay', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'PayAdminTransactionSwap', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'PayAdminTransactionSwap', batch: false, nostr: false, batchSize: 0}
@ -1878,6 +1985,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
}) })
if (!opts.allowNotImplementedMethods && !methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented')
app.post('/api/admin/swap/invoice/refund', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'RefundAdminInvoiceSwap', batch: false, nostr: false, batchSize: 0}
const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n }
let authCtx: Types.AuthContext = {}
try {
if (!methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented')
const authContext = await opts.AdminAuthGuard(req.headers['authorization'])
authCtx = authContext
stats.guard = process.hrtime.bigint()
const request = req.body
const error = Types.RefundAdminInvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback)
const query = req.query
const params = req.params
const response = await methods.RefundAdminInvoiceSwap({rpcName:'RefundAdminInvoiceSwap', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res.json({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
} catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
})
if (!opts.allowNotImplementedMethods && !methods.RequestNPubLinkingToken) throw new Error('method: RequestNPubLinkingToken is not implemented') if (!opts.allowNotImplementedMethods && !methods.RequestNPubLinkingToken) throw new Error('method: RequestNPubLinkingToken is not implemented')
app.post('/api/app/user/npub/token', async (req, res) => { app.post('/api/app/user/npub/token', async (req, res) => {
const info: Types.RequestInfo = { rpcName: 'RequestNPubLinkingToken', batch: false, nostr: false, batchSize: 0} const info: Types.RequestInfo = { rpcName: 'RequestNPubLinkingToken', batch: false, nostr: false, batchSize: 0}

View file

@ -176,6 +176,17 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
BumpTx: async (request: Types.BumpTx): Promise<ResultError | ({ status: 'OK' })> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/tx/bump'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
return data
}
return { status: 'ERROR', reason: 'invalid response' }
},
CloseChannel: async (request: Types.CloseChannelRequest): Promise<ResultError | ({ status: 'OK' }& Types.CloseChannelResponse)> => { CloseChannel: async (request: Types.CloseChannelRequest): Promise<ResultError | ({ status: 'OK' }& Types.CloseChannelResponse)> => {
const auth = await params.retrieveAdminAuth() const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null') if (auth === null) throw new Error('retrieveAdminAuth() returned null')
@ -273,6 +284,20 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
GetAdminInvoiceSwapQuotes: async (request: Types.InvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.InvoiceSwapQuoteList)> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/invoice/quote'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.InvoiceSwapQuoteListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => { GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => {
const auth = await params.retrieveAdminAuth() const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null') if (auth === null) throw new Error('retrieveAdminAuth() returned null')
@ -343,6 +368,20 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
GetAssetsAndLiabilities: async (request: Types.AssetsAndLiabilitiesReq): Promise<ResultError | ({ status: 'OK' }& Types.AssetsAndLiabilities)> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/assets/liabilities'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AssetsAndLiabilitiesValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
GetBundleMetrics: async (request: Types.LatestBundleMetricReq): Promise<ResultError | ({ status: 'OK' }& Types.BundleMetrics)> => { GetBundleMetrics: async (request: Types.LatestBundleMetricReq): Promise<ResultError | ({ status: 'OK' }& Types.BundleMetrics)> => {
const auth = await params.retrieveMetricsAuth() const auth = await params.retrieveMetricsAuth()
if (auth === null) throw new Error('retrieveMetricsAuth() returned null') if (auth === null) throw new Error('retrieveMetricsAuth() returned null')
@ -620,7 +659,7 @@ export default (params: ClientParams) => ({
GetTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => { GetTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => {
const auth = await params.retrieveUserAuth() const auth = await params.retrieveUserAuth()
if (auth === null) throw new Error('retrieveUserAuth() returned null') if (auth === null) throw new Error('retrieveUserAuth() returned null')
let finalRoute = '/api/user/swap/quote' let finalRoute = '/api/user/swap/transaction/quote'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') { if (data.status === 'OK') {
@ -781,16 +820,30 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
ListAdminSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.SwapsList)> => { ListAdminInvoiceSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.InvoiceSwapsList)> => {
const auth = await params.retrieveAdminAuth() const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null') if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/list' let finalRoute = '/api/admin/swap/invoice/list'
const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } }) const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.SwapsListValidate(result) const error = Types.InvoiceSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
ListAdminTxSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.TxSwapsList)> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/transaction/list'
const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.TxSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -809,16 +862,16 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
ListSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.SwapsList)> => { ListTxSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.TxSwapsList)> => {
const auth = await params.retrieveUserAuth() const auth = await params.retrieveUserAuth()
if (auth === null) throw new Error('retrieveUserAuth() returned null') if (auth === null) throw new Error('retrieveUserAuth() returned null')
let finalRoute = '/api/user/swap/list' let finalRoute = '/api/user/swap/transaction/list'
const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } }) const { data } = await axios.post(params.baseUrl + finalRoute, {}, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.SwapsListValidate(result) const error = Types.TxSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -909,7 +962,21 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminSwapResponse)> => { PayAdminInvoiceSwap: async (request: Types.PayAdminInvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminInvoiceSwapResponse)> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/invoice/pay'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminInvoiceSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminTxSwapResponse)> => {
const auth = await params.retrieveAdminAuth() const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null') if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/transaction/pay' let finalRoute = '/api/admin/swap/transaction/pay'
@ -918,7 +985,7 @@ export default (params: ClientParams) => ({
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminSwapResponseValidate(result) const error = Types.AdminTxSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -962,6 +1029,20 @@ export default (params: ClientParams) => ({
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
RefundAdminInvoiceSwap: async (request: Types.RefundAdminInvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminInvoiceSwapResponse)> => {
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
let finalRoute = '/api/admin/swap/invoice/refund'
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminInvoiceSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
RequestNPubLinkingToken: async (request: Types.RequestNPubLinkingTokenRequest): Promise<ResultError | ({ status: 'OK' }& Types.RequestNPubLinkingTokenResponse)> => { RequestNPubLinkingToken: async (request: Types.RequestNPubLinkingTokenRequest): Promise<ResultError | ({ status: 'OK' }& Types.RequestNPubLinkingTokenResponse)> => {
const auth = await params.retrieveAppAuth() const auth = await params.retrieveAppAuth()
if (auth === null) throw new Error('retrieveAppAuth() returned null') if (auth === null) throw new Error('retrieveAppAuth() returned null')

View file

@ -137,6 +137,18 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
BumpTx: async (request: Types.BumpTx): Promise<ResultError | ({ status: 'OK' })> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
nostrRequest.body = request
const data = await send(params.pubDestination, {rpcName:'BumpTx',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
return data
}
return { status: 'ERROR', reason: 'invalid response' }
},
CloseChannel: async (request: Types.CloseChannelRequest): Promise<ResultError | ({ status: 'OK' }& Types.CloseChannelResponse)> => { CloseChannel: async (request: Types.CloseChannelRequest): Promise<ResultError | ({ status: 'OK' }& Types.CloseChannelResponse)> => {
const auth = await params.retrieveNostrAdminAuth() const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
@ -230,6 +242,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
GetAdminInvoiceSwapQuotes: async (request: Types.InvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.InvoiceSwapQuoteList)> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
nostrRequest.body = request
const data = await send(params.pubDestination, {rpcName:'GetAdminInvoiceSwapQuotes',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.InvoiceSwapQuoteListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => { GetAdminTransactionSwapQuotes: async (request: Types.TransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.TransactionSwapQuoteList)> => {
const auth = await params.retrieveNostrAdminAuth() const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
@ -260,6 +287,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
GetAssetsAndLiabilities: async (request: Types.AssetsAndLiabilitiesReq): Promise<ResultError | ({ status: 'OK' }& Types.AssetsAndLiabilities)> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
nostrRequest.body = request
const data = await send(params.pubDestination, {rpcName:'GetAssetsAndLiabilities',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AssetsAndLiabilitiesValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
GetBundleMetrics: async (request: Types.LatestBundleMetricReq): Promise<ResultError | ({ status: 'OK' }& Types.BundleMetrics)> => { GetBundleMetrics: async (request: Types.LatestBundleMetricReq): Promise<ResultError | ({ status: 'OK' }& Types.BundleMetrics)> => {
const auth = await params.retrieveNostrMetricsAuth() const auth = await params.retrieveNostrMetricsAuth()
if (auth === null) throw new Error('retrieveNostrMetricsAuth() returned null') if (auth === null) throw new Error('retrieveNostrMetricsAuth() returned null')
@ -666,16 +708,30 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
ListAdminSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.SwapsList)> => { ListAdminInvoiceSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.InvoiceSwapsList)> => {
const auth = await params.retrieveNostrAdminAuth() const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {} const nostrRequest: NostrRequest = {}
const data = await send(params.pubDestination, {rpcName:'ListAdminSwaps',authIdentifier:auth, ...nostrRequest }) const data = await send(params.pubDestination, {rpcName:'ListAdminInvoiceSwaps',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.SwapsListValidate(result) const error = Types.InvoiceSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
ListAdminTxSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.TxSwapsList)> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
const data = await send(params.pubDestination, {rpcName:'ListAdminTxSwaps',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.TxSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -694,16 +750,16 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
ListSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.SwapsList)> => { ListTxSwaps: async (): Promise<ResultError | ({ status: 'OK' }& Types.TxSwapsList)> => {
const auth = await params.retrieveNostrUserAuth() const auth = await params.retrieveNostrUserAuth()
if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') if (auth === null) throw new Error('retrieveNostrUserAuth() returned null')
const nostrRequest: NostrRequest = {} const nostrRequest: NostrRequest = {}
const data = await send(params.pubDestination, {rpcName:'ListSwaps',authIdentifier:auth, ...nostrRequest }) const data = await send(params.pubDestination, {rpcName:'ListTxSwaps',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.SwapsListValidate(result) const error = Types.TxSwapsListValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -798,7 +854,22 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminSwapResponse)> => { PayAdminInvoiceSwap: async (request: Types.PayAdminInvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminInvoiceSwapResponse)> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
nostrRequest.body = request
const data = await send(params.pubDestination, {rpcName:'PayAdminInvoiceSwap',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminInvoiceSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
PayAdminTransactionSwap: async (request: Types.PayAdminTransactionSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminTxSwapResponse)> => {
const auth = await params.retrieveNostrAdminAuth() const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null') if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {} const nostrRequest: NostrRequest = {}
@ -808,7 +879,7 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
if (data.status === 'OK') { if (data.status === 'OK') {
const result = data const result = data
if(!params.checkResult) return { status: 'OK', ...result } if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminSwapResponseValidate(result) const error = Types.AdminTxSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
@ -839,6 +910,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ
} }
return { status: 'ERROR', reason: 'invalid response' } return { status: 'ERROR', reason: 'invalid response' }
}, },
RefundAdminInvoiceSwap: async (request: Types.RefundAdminInvoiceSwapRequest): Promise<ResultError | ({ status: 'OK' }& Types.AdminInvoiceSwapResponse)> => {
const auth = await params.retrieveNostrAdminAuth()
if (auth === null) throw new Error('retrieveNostrAdminAuth() returned null')
const nostrRequest: NostrRequest = {}
nostrRequest.body = request
const data = await send(params.pubDestination, {rpcName:'RefundAdminInvoiceSwap',authIdentifier:auth, ...nostrRequest })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
const result = data
if(!params.checkResult) return { status: 'OK', ...result }
const error = Types.AdminInvoiceSwapResponseValidate(result)
if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message }
}
return { status: 'ERROR', reason: 'invalid response' }
},
ResetDebit: async (request: Types.DebitOperation): Promise<ResultError | ({ status: 'OK' })> => { ResetDebit: async (request: Types.DebitOperation): Promise<ResultError | ({ status: 'OK' })> => {
const auth = await params.retrieveNostrUserAuth() const auth = await params.retrieveNostrUserAuth()
if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') if (auth === null) throw new Error('retrieveNostrUserAuth() returned null')

View file

@ -427,12 +427,12 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) callsMetrics.push({ ...opInfo, ...opStats, ...ctx })
} }
break break
case 'ListSwaps': case 'ListTxSwaps':
if (!methods.ListSwaps) { if (!methods.ListTxSwaps) {
throw new Error('method not defined: ListSwaps') throw new Error('method not defined: ListTxSwaps')
} else { } else {
opStats.validate = opStats.guard opStats.validate = opStats.guard
const res = await methods.ListSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res }) const res = await methods.ListTxSwaps({...operation, ctx}); responses.push({ status: 'OK', ...res })
opStats.handle = process.hrtime.bigint() opStats.handle = process.hrtime.bigint()
callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) callsMetrics.push({ ...opInfo, ...opStats, ...ctx })
} }
@ -575,6 +575,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics]) opts.metricsCallback([{ ...info, ...stats, ...ctx }, ...callsMetrics])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'BumpTx':
try {
if (!methods.BumpTx) throw new Error('method: BumpTx is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
const request = req.body
const error = Types.BumpTxValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback)
await methods.BumpTx({rpcName:'BumpTx', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res({status: 'OK'})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'CloseChannel': case 'CloseChannel':
try { try {
if (!methods.CloseChannel) throw new Error('method: CloseChannel is not implemented') if (!methods.CloseChannel) throw new Error('method: CloseChannel is not implemented')
@ -687,6 +703,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'GetAdminInvoiceSwapQuotes':
try {
if (!methods.GetAdminInvoiceSwapQuotes) throw new Error('method: GetAdminInvoiceSwapQuotes is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
const request = req.body
const error = Types.InvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback)
const response = await methods.GetAdminInvoiceSwapQuotes({rpcName:'GetAdminInvoiceSwapQuotes', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'GetAdminTransactionSwapQuotes': case 'GetAdminTransactionSwapQuotes':
try { try {
if (!methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented') if (!methods.GetAdminTransactionSwapQuotes) throw new Error('method: GetAdminTransactionSwapQuotes is not implemented')
@ -719,6 +751,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'GetAssetsAndLiabilities':
try {
if (!methods.GetAssetsAndLiabilities) throw new Error('method: GetAssetsAndLiabilities is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
const request = req.body
const error = Types.AssetsAndLiabilitiesReqValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback)
const response = await methods.GetAssetsAndLiabilities({rpcName:'GetAssetsAndLiabilities', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'GetBundleMetrics': case 'GetBundleMetrics':
try { try {
if (!methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented') if (!methods.GetBundleMetrics) throw new Error('method: GetBundleMetrics is not implemented')
@ -1122,14 +1170,27 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'ListAdminSwaps': case 'ListAdminInvoiceSwaps':
try { try {
if (!methods.ListAdminSwaps) throw new Error('method: ListAdminSwaps is not implemented') if (!methods.ListAdminInvoiceSwaps) throw new Error('method: ListAdminInvoiceSwaps is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier) const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint() stats.guard = process.hrtime.bigint()
authCtx = authContext authCtx = authContext
stats.validate = stats.guard stats.validate = stats.guard
const response = await methods.ListAdminSwaps({rpcName:'ListAdminSwaps', ctx:authContext }) const response = await methods.ListAdminInvoiceSwaps({rpcName:'ListAdminInvoiceSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'ListAdminTxSwaps':
try {
if (!methods.ListAdminTxSwaps) throw new Error('method: ListAdminTxSwaps is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
stats.validate = stats.guard
const response = await methods.ListAdminTxSwaps({rpcName:'ListAdminTxSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint() stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response}) res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
@ -1148,14 +1209,14 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'ListSwaps': case 'ListTxSwaps':
try { try {
if (!methods.ListSwaps) throw new Error('method: ListSwaps is not implemented') if (!methods.ListTxSwaps) throw new Error('method: ListTxSwaps is not implemented')
const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier) const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint() stats.guard = process.hrtime.bigint()
authCtx = authContext authCtx = authContext
stats.validate = stats.guard stats.validate = stats.guard
const response = await methods.ListSwaps({rpcName:'ListSwaps', ctx:authContext }) const response = await methods.ListTxSwaps({rpcName:'ListTxSwaps', ctx:authContext })
stats.handle = process.hrtime.bigint() stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response}) res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
@ -1254,6 +1315,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'PayAdminInvoiceSwap':
try {
if (!methods.PayAdminInvoiceSwap) throw new Error('method: PayAdminInvoiceSwap is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
const request = req.body
const error = Types.PayAdminInvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback)
const response = await methods.PayAdminInvoiceSwap({rpcName:'PayAdminInvoiceSwap', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'PayAdminTransactionSwap': case 'PayAdminTransactionSwap':
try { try {
if (!methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented') if (!methods.PayAdminTransactionSwap) throw new Error('method: PayAdminTransactionSwap is not implemented')
@ -1299,6 +1376,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => {
opts.metricsCallback([{ ...info, ...stats, ...authContext }]) opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break break
case 'RefundAdminInvoiceSwap':
try {
if (!methods.RefundAdminInvoiceSwap) throw new Error('method: RefundAdminInvoiceSwap is not implemented')
const authContext = await opts.NostrAdminAuthGuard(req.appId, req.authIdentifier)
stats.guard = process.hrtime.bigint()
authCtx = authContext
const request = req.body
const error = Types.RefundAdminInvoiceSwapRequestValidate(request)
stats.validate = process.hrtime.bigint()
if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback)
const response = await methods.RefundAdminInvoiceSwap({rpcName:'RefundAdminInvoiceSwap', ctx:authContext , req: request})
stats.handle = process.hrtime.bigint()
res({status: 'OK', ...response})
opts.metricsCallback([{ ...info, ...stats, ...authContext }])
}catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e }
break
case 'ResetDebit': case 'ResetDebit':
try { try {
if (!methods.ResetDebit) throw new Error('method: ResetDebit is not implemented') if (!methods.ResetDebit) throw new Error('method: ResetDebit is not implemented')

File diff suppressed because it is too large Load diff

View file

@ -112,6 +112,20 @@ service LightningPub {
option (nostr) = true; option (nostr) = true;
}; };
rpc GetAssetsAndLiabilities(structs.AssetsAndLiabilitiesReq) returns (structs.AssetsAndLiabilities) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/assets/liabilities";
option (nostr) = true;
};
rpc BumpTx(structs.BumpTx) returns (structs.Empty) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/tx/bump";
option (nostr) = true;
};
rpc AddApp(structs.AddAppRequest) returns (structs.AuthApp) { rpc AddApp(structs.AddAppRequest) returns (structs.AuthApp) {
option (auth_type) = "Admin"; option (auth_type) = "Admin";
option (http_method) = "post"; option (http_method) = "post";
@ -175,6 +189,34 @@ service LightningPub {
option (nostr) = true; option (nostr) = true;
} }
rpc GetAdminInvoiceSwapQuotes(structs.InvoiceSwapRequest) returns (structs.InvoiceSwapQuoteList) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/swap/invoice/quote";
option (nostr) = true;
}
rpc ListAdminInvoiceSwaps(structs.Empty) returns (structs.InvoiceSwapsList) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/swap/invoice/list";
option (nostr) = true;
}
rpc PayAdminInvoiceSwap(structs.PayAdminInvoiceSwapRequest) returns (structs.AdminInvoiceSwapResponse) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/swap/invoice/pay";
option (nostr) = true;
}
rpc RefundAdminInvoiceSwap(structs.RefundAdminInvoiceSwapRequest) returns (structs.AdminInvoiceSwapResponse) {
option (auth_type) = "Admin";
option (http_method) = "post";
option (http_route) = "/api/admin/swap/invoice/refund";
option (nostr) = true;
}
rpc GetAdminTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList) { rpc GetAdminTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList) {
option (auth_type) = "Admin"; option (auth_type) = "Admin";
option (http_method) = "post"; option (http_method) = "post";
@ -182,17 +224,17 @@ service LightningPub {
option (nostr) = true; option (nostr) = true;
} }
rpc PayAdminTransactionSwap(structs.PayAdminTransactionSwapRequest) returns (structs.AdminSwapResponse) { rpc PayAdminTransactionSwap(structs.PayAdminTransactionSwapRequest) returns (structs.AdminTxSwapResponse) {
option (auth_type) = "Admin"; option (auth_type) = "Admin";
option (http_method) = "post"; option (http_method) = "post";
option (http_route) = "/api/admin/swap/transaction/pay"; option (http_route) = "/api/admin/swap/transaction/pay";
option (nostr) = true; option (nostr) = true;
} }
rpc ListAdminSwaps(structs.Empty) returns (structs.SwapsList) { rpc ListAdminTxSwaps(structs.Empty) returns (structs.TxSwapsList) {
option (auth_type) = "Admin"; option (auth_type) = "Admin";
option (http_method) = "post"; option (http_method) = "post";
option (http_route) = "/api/admin/swap/list"; option (http_route) = "/api/admin/swap/transaction/list";
option (nostr) = true; option (nostr) = true;
} }
@ -520,14 +562,14 @@ service LightningPub {
rpc GetTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList){ rpc GetTransactionSwapQuotes(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuoteList){
option (auth_type) = "User"; option (auth_type) = "User";
option (http_method) = "post"; option (http_method) = "post";
option (http_route) = "/api/user/swap/quote"; option (http_route) = "/api/user/swap/transaction/quote";
option (nostr) = true; option (nostr) = true;
} }
rpc ListSwaps(structs.Empty) returns (structs.SwapsList){ rpc ListTxSwaps(structs.Empty) returns (structs.TxSwapsList){
option (auth_type) = "User"; option (auth_type) = "User";
option (http_method) = "post"; option (http_method) = "post";
option (http_route) = "/api/user/swap/list"; option (http_route) = "/api/user/swap/transaction/list";
option (nostr) = true; option (nostr) = true;
} }

View file

@ -19,6 +19,68 @@ message EncryptionExchangeRequest {
string deviceId = 2; string deviceId = 2;
} }
message AssetsAndLiabilitiesReq {
optional int64 limit_invoices = 1;
optional int64 limit_payments = 2;
optional int64 limit_providers = 4;
}
message BumpTx {
string txid = 1;
int64 output_index = 2;
int64 sat_per_vbyte = 3;
}
enum TrackedOperationType {
USER = 0;
ROOT = 1;
}
message TrackedOperation {
int64 ts = 1;
int64 amount = 2;
TrackedOperationType type = 3;
}
message AssetOperation {
int64 ts = 1;
int64 amount = 2;
optional TrackedOperation tracked = 3;
}
message TrackedLndProvider {
int64 confirmed_balance = 1;
int64 unconfirmed_balance = 2;
int64 channels_balance = 3;
repeated AssetOperation payments = 4;
repeated AssetOperation invoices = 5;
repeated AssetOperation incoming_tx = 6;
repeated AssetOperation outgoing_tx = 7;
}
message TrackedLiquidityProvider {
int64 balance = 1;
repeated AssetOperation payments = 2;
repeated AssetOperation invoices = 3;
}
message LndAssetProvider {
string pubkey = 1;
optional TrackedLndProvider tracked = 2;
}
message LiquidityAssetProvider {
string pubkey = 1;
optional TrackedLiquidityProvider tracked = 2;
}
message AssetsAndLiabilities {
int64 users_balance = 1;
repeated LndAssetProvider lnds = 2;
repeated LiquidityAssetProvider liquidity_providers = 3;
}
message UserHealthState { message UserHealthState {
string downtime_reason = 1; string downtime_reason = 1;
} }
@ -37,6 +99,8 @@ message ErrorStats {
ErrorStat past1m = 5; ErrorStat past1m = 5;
} }
message MetricsFile { message MetricsFile {
} }
@ -541,6 +605,7 @@ message UserInfo{
string callback_url = 10; string callback_url = 10;
string bridge_url = 11; string bridge_url = 11;
string nmanage = 12; string nmanage = 12;
string topic_id = 13;
} }
@ -833,6 +898,56 @@ message MessagingToken {
string firebase_messaging_token = 2; string firebase_messaging_token = 2;
} }
message InvoiceSwapRequest {
int64 amount_sats = 1;
}
message InvoiceSwapQuote {
string swap_operation_id = 1;
string invoice = 2;
int64 invoice_amount_sats = 3;
string address = 4;
int64 transaction_amount_sats = 5;
int64 chain_fee_sats = 6;
int64 service_fee_sats = 7;
string service_url = 8;
int64 swap_fee_sats = 9;
string tx_id = 10;
int64 paid_at_unix = 11;
int64 expires_at_block_height = 12;
}
message InvoiceSwapQuoteList {
repeated InvoiceSwapQuote quotes = 1;
}
message InvoiceSwapOperation {
InvoiceSwapQuote quote = 1;
optional UserOperation operation_payment = 2;
optional string failure_reason = 3;
optional int64 completed_at_unix = 6;
}
message InvoiceSwapsList {
repeated InvoiceSwapOperation swaps = 1;
int64 current_block_height = 3;
}
message RefundAdminInvoiceSwapRequest {
string swap_operation_id = 1;
int64 sat_per_v_byte = 2;
}
message PayAdminInvoiceSwapRequest {
string swap_operation_id = 1;
int64 sat_per_v_byte = 2;
optional bool no_claim = 3;
}
message AdminInvoiceSwapResponse {
string tx_id = 1;
}
message TransactionSwapRequest { message TransactionSwapRequest {
int64 transaction_amount_sats = 2; int64 transaction_amount_sats = 2;
} }
@ -851,27 +966,31 @@ message TransactionSwapQuote {
int64 chain_fee_sats = 5; int64 chain_fee_sats = 5;
int64 service_fee_sats = 7; int64 service_fee_sats = 7;
string service_url = 8; string service_url = 8;
int64 expires_at_block_height = 9;
int64 paid_at_unix = 10;
int64 completed_at_unix = 11;
} }
message TransactionSwapQuoteList { message TransactionSwapQuoteList {
repeated TransactionSwapQuote quotes = 1; repeated TransactionSwapQuote quotes = 1;
} }
message AdminSwapResponse { message AdminTxSwapResponse {
string tx_id = 1; string tx_id = 1;
int64 network_fee = 2; int64 network_fee = 2;
} }
message SwapOperation { message TxSwapOperation {
string swap_operation_id = 1; TransactionSwapQuote quote = 1;
optional UserOperation operation_payment = 2; optional UserOperation operation_payment = 2;
optional string failure_reason = 3; optional string failure_reason = 3;
string address_paid = 4; optional string address_paid = 4;
optional string tx_id = 5;
} }
message SwapsList { message TxSwapsList {
repeated SwapOperation swaps = 1; repeated TxSwapOperation swaps = 1;
repeated TransactionSwapQuote quotes = 2;
} }
message CumulativeFees { message CumulativeFees {
@ -886,3 +1005,18 @@ message BeaconData {
optional string nextRelay = 4; optional string nextRelay = 4;
optional CumulativeFees fees = 5; optional CumulativeFees fees = 5;
} }
message PushNotificationEnvelope {
string topic_id = 1;
string app_npub_hex = 2;
string encrypted_payload = 3; // encrypted PushNotificationPayload
}
message PushNotificationPayload {
oneof data {
UserOperation received_operation = 1;
UserOperation sent_operation = 2;
}
}

731
src/extensions/README.md Normal file
View file

@ -0,0 +1,731 @@
# Lightning.Pub Extension System
A modular extension system that allows third-party functionality to be added to Lightning.Pub without modifying core code.
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Creating an Extension](#creating-an-extension)
- [Extension Lifecycle](#extension-lifecycle)
- [ExtensionContext API](#extensioncontext-api)
- [Database Isolation](#database-isolation)
- [RPC Methods](#rpc-methods)
- [HTTP Routes](#http-routes)
- [Event Handling](#event-handling)
- [Configuration](#configuration)
- [Examples](#examples)
---
## Overview
The extension system provides:
- **Modularity**: Extensions are self-contained modules with their own code and data
- **Isolation**: Each extension gets its own SQLite database
- **Integration**: Extensions can register RPC methods, handle events, and interact with Lightning.Pub's payment and Nostr systems
- **Lifecycle Management**: Automatic discovery, loading, and graceful shutdown
### Built-in Extensions
| Extension | Description |
|-----------|-------------|
| `marketplace` | NIP-15 Nostr marketplace for selling products via Lightning |
| `withdraw` | LNURL-withdraw (LUD-03) for vouchers, faucets, and gifts |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Lightning.Pub │
├─────────────────────────────────────────────────────────────────┤
│ Extension Loader │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Extension A │ │ Extension B │ │ Extension C │ ... │
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │Context│ │ │ │Context│ │ │ │Context│ │ │
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │ DB │ │ │ │ DB │ │ │ │ DB │ │ │
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Payment Manager │ Nostr Transport │ Application Manager │
└─────────────────────────────────────────────────────────────────┘
```
### Key Components
| Component | File | Description |
|-----------|------|-------------|
| `ExtensionLoader` | `loader.ts` | Discovers, loads, and manages extensions |
| `ExtensionContext` | `context.ts` | Bridge between extensions and Lightning.Pub |
| `ExtensionDatabase` | `database.ts` | Isolated SQLite database per extension |
---
## Creating an Extension
### Directory Structure
```
src/extensions/
└── my-extension/
├── index.ts # Main entry point (required)
├── types.ts # TypeScript interfaces
├── migrations.ts # Database migrations
└── managers/ # Business logic
└── myManager.ts
```
### Minimal Extension
```typescript
// src/extensions/my-extension/index.ts
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
export default class MyExtension implements Extension {
readonly info: ExtensionInfo = {
id: 'my-extension', // Must match directory name
name: 'My Extension',
version: '1.0.0',
description: 'Does something useful',
author: 'Your Name',
minPubVersion: '1.0.0' // Minimum Lightning.Pub version
}
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
// Run migrations
await db.execute(`
CREATE TABLE IF NOT EXISTS my_table (
id TEXT PRIMARY KEY,
data TEXT
)
`)
// Register RPC methods
ctx.registerMethod('my-extension.doSomething', async (req, appId) => {
return { result: 'done' }
})
ctx.log('info', 'Extension initialized')
}
async shutdown(): Promise<void> {
// Cleanup resources
}
}
```
### Extension Interface
```typescript
interface Extension {
// Required: Extension metadata
readonly info: ExtensionInfo
// Required: Called once when extension is loaded
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
// Optional: Called when Lightning.Pub shuts down
shutdown?(): Promise<void>
// Optional: Health check for monitoring
healthCheck?(): Promise<boolean>
}
interface ExtensionInfo {
id: string // Unique identifier (lowercase, no spaces)
name: string // Display name
version: string // Semver version
description: string // Short description
author: string // Author name
minPubVersion?: string // Minimum Lightning.Pub version
dependencies?: string[] // Other extension IDs required
}
```
---
## Extension Lifecycle
```
┌──────────────┐
│ Discover │ Scan extensions directory for index.ts files
└──────┬───────┘
┌──────────────┐
│ Load │ Import module, instantiate class
└──────┬───────┘
┌──────────────┐
│ Initialize │ Create database, call initialize()
└──────┬───────┘
┌──────────────┐
│ Ready │ Extension is active, handling requests
└──────┬───────┘
▼ (on shutdown)
┌──────────────┐
│ Shutdown │ Call shutdown(), close database
└──────────────┘
```
### States
| State | Description |
|-------|-------------|
| `loading` | Extension is being loaded |
| `ready` | Extension is active and healthy |
| `error` | Initialization failed |
| `stopped` | Extension has been shut down |
---
## ExtensionContext API
The `ExtensionContext` is passed to your extension during initialization. It provides access to Lightning.Pub functionality.
### Application Management
```typescript
// Get information about an application
const app = await ctx.getApplication(applicationId)
// Returns: { id, name, nostr_public, balance_sats } | null
```
### Payment Operations
```typescript
// Create a Lightning invoice
const invoice = await ctx.createInvoice(amountSats, {
memo: 'Payment for service',
expiry: 3600, // seconds
metadata: { order_id: '123' } // Returned in payment callback
})
// Returns: { id, paymentRequest, paymentHash, expiry }
// Pay a Lightning invoice
const result = await ctx.payInvoice(applicationId, bolt11Invoice, maxFeeSats)
// Returns: { paymentHash, feeSats }
```
### Nostr Operations
```typescript
// Send encrypted DM (NIP-44)
const eventId = await ctx.sendEncryptedDM(applicationId, recipientPubkey, content)
// Publish a Nostr event (signed by application's key)
const eventId = await ctx.publishNostrEvent({
kind: 30017,
pubkey: appPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'identifier']],
content: JSON.stringify(data)
})
```
### RPC Method Registration
```typescript
// Register a method that can be called via RPC
ctx.registerMethod('my-extension.methodName', async (request, applicationId, userPubkey?) => {
// request: The RPC request payload
// applicationId: The calling application's ID
// userPubkey: The user's Nostr pubkey (if authenticated)
return { result: 'success' }
})
```
### Event Subscriptions
```typescript
// Subscribe to payment received events
ctx.onPaymentReceived(async (payment) => {
// payment: { invoiceId, paymentHash, amountSats, metadata }
if (payment.metadata?.extension === 'my-extension') {
// Handle payment for this extension
}
})
// Subscribe to incoming Nostr events
ctx.onNostrEvent(async (event, applicationId) => {
// event: { id, pubkey, kind, tags, content, created_at }
// applicationId: The application this event is for
if (event.kind === 4) { // DM
// Handle incoming message
}
})
```
### Logging
```typescript
ctx.log('debug', 'Detailed debugging info')
ctx.log('info', 'Normal operation info')
ctx.log('warn', 'Warning message')
ctx.log('error', 'Error occurred', errorObject)
```
---
## Database Isolation
Each extension gets its own SQLite database file at:
```
{databaseDir}/{extension-id}.db
```
### Database Interface
```typescript
interface ExtensionDatabase {
// Execute write queries (INSERT, UPDATE, DELETE, CREATE)
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
// Execute read queries (SELECT)
query<T>(sql: string, params?: any[]): Promise<T[]>
// Run multiple statements in a transaction
transaction<T>(fn: () => Promise<T>): Promise<T>
}
```
### Migration Pattern
```typescript
// migrations.ts
export interface Migration {
version: number
name: string
up: (db: ExtensionDatabase) => Promise<void>
}
export const migrations: Migration[] = [
{
version: 1,
name: 'create_initial_tables',
up: async (db) => {
await db.execute(`
CREATE TABLE items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`)
}
},
{
version: 2,
name: 'add_status_column',
up: async (db) => {
await db.execute(`ALTER TABLE items ADD COLUMN status TEXT DEFAULT 'active'`)
}
}
]
// Run migrations in initialize()
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
const result = await db.query<{ value: string }>(
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
).catch(() => [])
const currentVersion = result.length > 0 ? parseInt(result[0].value, 10) : 0
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`Running migration ${migration.version}: ${migration.name}`)
await migration.up(db)
await db.execute(
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[String(migration.version)]
)
}
}
}
```
---
## RPC Methods
Extensions register RPC methods that can be called by clients.
### Naming Convention
Methods should be namespaced with the extension ID:
```
{extension-id}.{methodName}
```
Examples:
- `marketplace.createStall`
- `withdraw.createLink`
### Method Handler Signature
```typescript
type RpcMethodHandler = (
request: any, // The request payload
applicationId: string, // The calling application
userPubkey?: string // The authenticated user (if any)
) => Promise<any>
```
### Example
```typescript
ctx.registerMethod('my-extension.createItem', async (req, appId, userPubkey) => {
// Validate request
if (!req.name) {
throw new Error('Name is required')
}
// Create item
const item = await this.manager.create(appId, req)
// Return response
return { item }
})
```
---
## HTTP Routes
Some extensions need HTTP endpoints (e.g., LNURL protocol). Extensions can define routes that the main application mounts.
### Defining Routes
```typescript
interface HttpRoute {
method: 'GET' | 'POST'
path: string
handler: (req: HttpRequest) => Promise<HttpResponse>
}
interface HttpRequest {
params: Record<string, string> // URL path params
query: Record<string, string> // Query string params
body?: any // POST body
headers: Record<string, string>
}
interface HttpResponse {
status: number
body: any
headers?: Record<string, string>
}
```
### Example
```typescript
class MyExtension implements Extension {
getHttpRoutes(): HttpRoute[] {
return [
{
method: 'GET',
path: '/api/v1/my-extension/:id',
handler: async (req) => {
const item = await this.getItem(req.params.id)
return {
status: 200,
body: item,
headers: { 'Content-Type': 'application/json' }
}
}
}
]
}
}
```
---
## Event Handling
### Payment Callbacks
When you create an invoice with metadata, you'll receive that metadata back in the payment callback:
```typescript
// Creating invoice with metadata
const invoice = await ctx.createInvoice(1000, {
metadata: {
extension: 'my-extension',
order_id: 'order-123'
}
})
// Handling payment
ctx.onPaymentReceived(async (payment) => {
if (payment.metadata?.extension === 'my-extension') {
const orderId = payment.metadata.order_id
await this.handlePayment(orderId, payment)
}
})
```
### Nostr Events
Subscribe to Nostr events for your application:
```typescript
ctx.onNostrEvent(async (event, applicationId) => {
// Filter by event kind
if (event.kind === 4) { // Encrypted DM
await this.handleDirectMessage(event, applicationId)
}
})
```
---
## Configuration
### Loader Configuration
```typescript
interface ExtensionLoaderConfig {
extensionsDir: string // Directory containing extensions
databaseDir: string // Directory for extension databases
enabledExtensions?: string[] // Whitelist (if set, only these load)
disabledExtensions?: string[] // Blacklist
}
```
### Usage
```typescript
import { createExtensionLoader } from './extensions'
const loader = createExtensionLoader({
extensionsDir: './src/extensions',
databaseDir: './data/extensions',
disabledExtensions: ['experimental-ext']
}, mainHandler)
await loader.loadAll()
// Call extension methods
const result = await loader.callMethod(
'marketplace.createStall',
{ name: 'My Shop', currency: 'sat', shipping_zones: [] },
applicationId,
userPubkey
)
// Dispatch events
loader.dispatchPaymentReceived(paymentData)
loader.dispatchNostrEvent(event, applicationId)
// Shutdown
await loader.shutdown()
```
---
## Examples
### Example: Simple Counter Extension
```typescript
// src/extensions/counter/index.ts
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
export default class CounterExtension implements Extension {
readonly info: ExtensionInfo = {
id: 'counter',
name: 'Simple Counter',
version: '1.0.0',
description: 'A simple counter for each application',
author: 'Example'
}
private db!: ExtensionDatabase
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
this.db = db
await db.execute(`
CREATE TABLE IF NOT EXISTS counters (
application_id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
)
`)
ctx.registerMethod('counter.increment', async (req, appId) => {
await db.execute(
`INSERT INTO counters (application_id, count) VALUES (?, 1)
ON CONFLICT(application_id) DO UPDATE SET count = count + 1`,
[appId]
)
const result = await db.query<{ count: number }>(
'SELECT count FROM counters WHERE application_id = ?',
[appId]
)
return { count: result[0]?.count || 0 }
})
ctx.registerMethod('counter.get', async (req, appId) => {
const result = await db.query<{ count: number }>(
'SELECT count FROM counters WHERE application_id = ?',
[appId]
)
return { count: result[0]?.count || 0 }
})
ctx.registerMethod('counter.reset', async (req, appId) => {
await db.execute(
'UPDATE counters SET count = 0 WHERE application_id = ?',
[appId]
)
return { count: 0 }
})
}
}
```
### Example: Payment-Triggered Extension
```typescript
// src/extensions/donations/index.ts
import { Extension, ExtensionContext, ExtensionDatabase } from '../types.js'
export default class DonationsExtension implements Extension {
readonly info = {
id: 'donations',
name: 'Donations',
version: '1.0.0',
description: 'Accept donations with thank-you messages',
author: 'Example'
}
private db!: ExtensionDatabase
private ctx!: ExtensionContext
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
this.db = db
this.ctx = ctx
await db.execute(`
CREATE TABLE IF NOT EXISTS donations (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
donor_pubkey TEXT,
message TEXT,
created_at INTEGER NOT NULL
)
`)
// Create donation invoice
ctx.registerMethod('donations.createInvoice', async (req, appId) => {
const invoice = await ctx.createInvoice(req.amount_sats, {
memo: req.message || 'Donation',
metadata: {
extension: 'donations',
donor_pubkey: req.donor_pubkey,
message: req.message
}
})
return { invoice: invoice.paymentRequest }
})
// Handle successful payments
ctx.onPaymentReceived(async (payment) => {
if (payment.metadata?.extension !== 'donations') return
// Record donation
await db.execute(
`INSERT INTO donations (id, application_id, amount_sats, donor_pubkey, message, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[
payment.paymentHash,
payment.metadata.application_id,
payment.amountSats,
payment.metadata.donor_pubkey,
payment.metadata.message,
Math.floor(Date.now() / 1000)
]
)
// Send thank-you DM if donor has pubkey
if (payment.metadata.donor_pubkey) {
await ctx.sendEncryptedDM(
payment.metadata.application_id,
payment.metadata.donor_pubkey,
`Thank you for your donation of ${payment.amountSats} sats!`
)
}
})
// List donations
ctx.registerMethod('donations.list', async (req, appId) => {
const donations = await db.query(
`SELECT * FROM donations WHERE application_id = ? ORDER BY created_at DESC LIMIT ?`,
[appId, req.limit || 50]
)
return { donations }
})
}
}
```
---
## Best Practices
1. **Namespace your methods**: Always prefix RPC methods with your extension ID
2. **Use migrations**: Never modify existing migration files; create new ones
3. **Handle errors gracefully**: Throw descriptive errors, don't return error objects
4. **Clean up in shutdown**: Close connections, cancel timers, etc.
5. **Log appropriately**: Use debug for verbose info, error for failures
6. **Validate inputs**: Check request parameters before processing
7. **Use transactions**: For multi-step database operations
8. **Document your API**: Include types and descriptions for RPC methods
---
## Troubleshooting
### Extension not loading
1. Check that directory name matches `info.id`
2. Verify `index.ts` has a default export
3. Check for TypeScript/import errors in logs
### Database errors
1. Check migration syntax
2. Verify column types match queries
3. Look for migration version conflicts
### RPC method not found
1. Verify method is registered in `initialize()`
2. Check method name includes extension prefix
3. Ensure extension status is `ready`
### Payment callbacks not firing
1. Verify `metadata.extension` matches your extension ID
2. Check that `onPaymentReceived` is registered in `initialize()`
3. Confirm invoice was created through the extension

324
src/extensions/context.ts Normal file
View file

@ -0,0 +1,324 @@
import {
ExtensionContext,
ExtensionDatabase,
ExtensionInfo,
ApplicationInfo,
CreateInvoiceOptions,
CreatedInvoice,
PaymentReceivedData,
NostrEvent,
UnsignedNostrEvent,
RpcMethodHandler,
LnurlPayInfo
} from './types.js'
/**
* Main Handler interface (from Lightning.Pub)
* This is a minimal interface - the actual MainHandler has more methods
*/
export interface MainHandlerInterface {
// Application management
applicationManager: {
getById(id: string): Promise<any>
PayAppUserInvoice(appId: string, req: {
amount: number
invoice: string
user_identifier: string
debit_npub?: string
}): Promise<{
preimage: string
amount_paid: number
network_fee: number
service_fee: number
}>
}
// Payment operations
paymentManager: {
createInvoice(params: {
applicationId: string
amountSats: number
memo?: string
expiry?: number
metadata?: Record<string, any>
}): Promise<{
id: string
paymentRequest: string
paymentHash: string
expiry: number
}>
payInvoice(params: {
applicationId: string
paymentRequest: string
maxFeeSats?: number
userPubkey?: string
}): Promise<{
paymentHash: string
feeSats: number
}>
/**
* Get LNURL-pay info for a user by their Nostr pubkey
* This enables Lightning Address (LUD-16) and zap (NIP-57) support
*/
getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
metadata?: string
description?: string
}): Promise<LnurlPayInfo>
}
// Nostr operations
sendNostrEvent(event: any): Promise<string | null>
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
}
/**
* Callback registries for extension events
*/
interface CallbackRegistries {
paymentReceived: Array<(payment: PaymentReceivedData) => Promise<void>>
nostrEvent: Array<(event: NostrEvent, applicationId: string) => Promise<void>>
}
/**
* Registered RPC method
*/
interface RegisteredMethod {
extensionId: string
handler: RpcMethodHandler
}
/**
* Extension Context Implementation
*
* Provides the interface for extensions to interact with Lightning.Pub.
* Each extension gets its own context instance.
*/
export class ExtensionContextImpl implements ExtensionContext {
private callbacks: CallbackRegistries = {
paymentReceived: [],
nostrEvent: []
}
constructor(
private extensionInfo: ExtensionInfo,
private database: ExtensionDatabase,
private mainHandler: MainHandlerInterface,
private methodRegistry: Map<string, RegisteredMethod>
) {}
/**
* Get information about an application
*/
async getApplication(applicationId: string): Promise<ApplicationInfo | null> {
try {
const app = await this.mainHandler.applicationManager.getById(applicationId)
if (!app) return null
return {
id: app.id,
name: app.name,
nostr_public: app.nostr_public,
balance_sats: app.balance || 0
}
} catch (e) {
this.log('error', `Failed to get application ${applicationId}:`, e)
return null
}
}
/**
* Create a Lightning invoice
*/
async createInvoice(amountSats: number, options: CreateInvoiceOptions = {}): Promise<CreatedInvoice> {
// Note: In practice, this needs an applicationId. Extensions typically
// get this from the RPC request context. For now, we'll need to handle
// this in the actual implementation.
throw new Error('createInvoice requires applicationId from request context')
}
/**
* Create invoice with explicit application ID
* This is the internal method used by extensions
*/
async createInvoiceForApp(
applicationId: string,
amountSats: number,
options: CreateInvoiceOptions = {}
): Promise<CreatedInvoice> {
const result = await this.mainHandler.paymentManager.createInvoice({
applicationId,
amountSats,
memo: options.memo,
expiry: options.expiry,
metadata: {
...options.metadata,
extension: this.extensionInfo.id
}
})
return {
id: result.id,
paymentRequest: result.paymentRequest,
paymentHash: result.paymentHash,
expiry: result.expiry
}
}
/**
* Pay a Lightning invoice
* If userPubkey is provided, pays from that user's balance instead of app.owner
*/
async payInvoice(
applicationId: string,
paymentRequest: string,
maxFeeSats?: number,
userPubkey?: string
): Promise<{ paymentHash: string; feeSats: number }> {
return this.mainHandler.paymentManager.payInvoice({
applicationId,
paymentRequest,
maxFeeSats,
userPubkey
})
}
/**
* Send an encrypted DM via Nostr
*/
async sendEncryptedDM(
applicationId: string,
recipientPubkey: string,
content: string
): Promise<string> {
return this.mainHandler.sendEncryptedDM(applicationId, recipientPubkey, content)
}
/**
* Publish a Nostr event
*/
async publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null> {
return this.mainHandler.sendNostrEvent(event)
}
/**
* Get LNURL-pay info for a user by pubkey
* Enables Lightning Address and zap support
*/
async getLnurlPayInfo(pubkeyHex: string, options?: {
metadata?: string
description?: string
}): Promise<LnurlPayInfo> {
return this.mainHandler.paymentManager.getLnurlPayInfoByPubkey(pubkeyHex, options)
}
/**
* Subscribe to payment received callbacks
*/
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void {
this.callbacks.paymentReceived.push(callback)
}
/**
* Subscribe to incoming Nostr events
*/
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void {
this.callbacks.nostrEvent.push(callback)
}
/**
* Register an RPC method
*/
registerMethod(name: string, handler: RpcMethodHandler): void {
const fullName = name.startsWith(`${this.extensionInfo.id}.`)
? name
: `${this.extensionInfo.id}.${name}`
if (this.methodRegistry.has(fullName)) {
throw new Error(`RPC method ${fullName} already registered`)
}
this.methodRegistry.set(fullName, {
extensionId: this.extensionInfo.id,
handler
})
this.log('debug', `Registered RPC method: ${fullName}`)
}
/**
* Get the extension's database
*/
getDatabase(): ExtensionDatabase {
return this.database
}
/**
* Log a message
*/
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void {
const prefix = `[Extension:${this.extensionInfo.id}]`
switch (level) {
case 'debug':
console.debug(prefix, message, ...args)
break
case 'info':
console.info(prefix, message, ...args)
break
case 'warn':
console.warn(prefix, message, ...args)
break
case 'error':
console.error(prefix, message, ...args)
break
}
}
// ===== Internal Methods (called by ExtensionLoader) =====
/**
* Dispatch payment received event to extension callbacks
*/
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
for (const callback of this.callbacks.paymentReceived) {
try {
await callback(payment)
} catch (e) {
this.log('error', 'Error in payment callback:', e)
}
}
}
/**
* Dispatch Nostr event to extension callbacks
*/
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
for (const callback of this.callbacks.nostrEvent) {
try {
await callback(event, applicationId)
} catch (e) {
this.log('error', 'Error in Nostr event callback:', e)
}
}
}
/**
* Get registered callbacks for external access
*/
getCallbacks(): CallbackRegistries {
return this.callbacks
}
}
/**
* Create an extension context
*/
export function createExtensionContext(
extensionInfo: ExtensionInfo,
database: ExtensionDatabase,
mainHandler: MainHandlerInterface,
methodRegistry: Map<string, RegisteredMethod>
): ExtensionContextImpl {
return new ExtensionContextImpl(extensionInfo, database, mainHandler, methodRegistry)
}

148
src/extensions/database.ts Normal file
View file

@ -0,0 +1,148 @@
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { ExtensionDatabase } from './types.js'
/**
* Extension Database Implementation
*
* Provides isolated SQLite database access for each extension.
* Uses better-sqlite3 for synchronous, high-performance access.
*/
export class ExtensionDatabaseImpl implements ExtensionDatabase {
private db: Database.Database
private extensionId: string
constructor(extensionId: string, databaseDir: string) {
this.extensionId = extensionId
// Ensure database directory exists
if (!fs.existsSync(databaseDir)) {
fs.mkdirSync(databaseDir, { recursive: true })
}
// Create database file for this extension
const dbPath = path.join(databaseDir, `${extensionId}.db`)
this.db = new Database(dbPath)
// Enable WAL mode for better concurrency
this.db.pragma('journal_mode = WAL')
// Enable foreign keys
this.db.pragma('foreign_keys = ON')
// Create metadata table for tracking migrations
this.db.exec(`
CREATE TABLE IF NOT EXISTS _extension_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)
}
/**
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
*/
async execute(sql: string, params: any[] = []): Promise<{ changes?: number; lastId?: number }> {
try {
const stmt = this.db.prepare(sql)
const result = stmt.run(...params)
return {
changes: result.changes,
lastId: result.lastInsertRowid as number
}
} catch (e) {
console.error(`[Extension:${this.extensionId}] Database execute error:`, e)
throw e
}
}
/**
* Execute a read query (SELECT)
*/
async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
try {
const stmt = this.db.prepare(sql)
return stmt.all(...params) as T[]
} catch (e) {
console.error(`[Extension:${this.extensionId}] Database query error:`, e)
throw e
}
}
/**
* Execute multiple statements in a transaction
*/
async transaction<T>(fn: () => Promise<T>): Promise<T> {
const runTransaction = this.db.transaction(() => {
// Note: better-sqlite3 transactions are synchronous
// We wrap the async function but it executes synchronously
return fn()
})
return runTransaction() as T
}
/**
* Get a metadata value
*/
async getMeta(key: string): Promise<string | null> {
const rows = await this.query<{ value: string }>(
'SELECT value FROM _extension_meta WHERE key = ?',
[key]
)
return rows.length > 0 ? rows[0].value : null
}
/**
* Set a metadata value
*/
async setMeta(key: string, value: string): Promise<void> {
await this.execute(
`INSERT INTO _extension_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value]
)
}
/**
* Get current migration version
*/
async getMigrationVersion(): Promise<number> {
const version = await this.getMeta('migration_version')
return version ? parseInt(version, 10) : 0
}
/**
* Set migration version
*/
async setMigrationVersion(version: number): Promise<void> {
await this.setMeta('migration_version', String(version))
}
/**
* Close the database connection
*/
close(): void {
this.db.close()
}
/**
* Get the underlying database for advanced operations
* (Use with caution - bypasses isolation)
*/
getUnderlyingDb(): Database.Database {
return this.db
}
}
/**
* Create an extension database instance
*/
export function createExtensionDatabase(
extensionId: string,
databaseDir: string
): ExtensionDatabaseImpl {
return new ExtensionDatabaseImpl(extensionId, databaseDir)
}

56
src/extensions/index.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* Lightning.Pub Extension System
*
* This module provides the extension infrastructure for Lightning.Pub.
* Extensions can add functionality like marketplaces, subscriptions,
* tipping, and more.
*
* Usage:
*
* ```typescript
* import { createExtensionLoader, ExtensionLoaderConfig } from './extensions'
*
* const config: ExtensionLoaderConfig = {
* extensionsDir: './extensions',
* databaseDir: './data/extensions'
* }
*
* const loader = createExtensionLoader(config, mainHandler)
* await loader.loadAll()
*
* // Call extension methods
* const result = await loader.callMethod(
* 'marketplace.createStall',
* { name: 'My Shop', currency: 'sat', shipping_zones: [...] },
* applicationId
* )
* ```
*/
// Export types
export {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
ExtensionModule,
ExtensionConstructor,
LoadedExtension,
ExtensionLoaderConfig,
ApplicationInfo,
CreateInvoiceOptions,
CreatedInvoice,
PaymentReceivedData,
NostrEvent,
UnsignedNostrEvent,
RpcMethodHandler
} from './types.js'
// Export loader
export { ExtensionLoader, createExtensionLoader } from './loader.js'
// Export database utilities
export { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
// Export context utilities
export { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'

406
src/extensions/loader.ts Normal file
View file

@ -0,0 +1,406 @@
import path from 'path'
import fs from 'fs'
import {
Extension,
ExtensionInfo,
ExtensionModule,
LoadedExtension,
ExtensionLoaderConfig,
RpcMethodHandler,
PaymentReceivedData,
NostrEvent
} from './types.js'
import { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
import { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'
/**
* Registered RPC method entry
*/
interface RegisteredMethod {
extensionId: string
handler: RpcMethodHandler
}
/**
* Extension Loader
*
* Discovers, loads, and manages Lightning.Pub extensions.
* Provides lifecycle management and event dispatching.
*/
export class ExtensionLoader {
private config: ExtensionLoaderConfig
private mainHandler: MainHandlerInterface
private extensions: Map<string, LoadedExtension> = new Map()
private contexts: Map<string, ExtensionContextImpl> = new Map()
private methodRegistry: Map<string, RegisteredMethod> = new Map()
private initialized = false
constructor(config: ExtensionLoaderConfig, mainHandler: MainHandlerInterface) {
this.config = config
this.mainHandler = mainHandler
}
/**
* Discover and load all extensions
*/
async loadAll(): Promise<void> {
if (this.initialized) {
throw new Error('Extension loader already initialized')
}
console.log('[Extensions] Loading extensions from:', this.config.extensionsDir)
// Ensure directories exist
if (!fs.existsSync(this.config.extensionsDir)) {
console.log('[Extensions] Extensions directory does not exist, creating...')
fs.mkdirSync(this.config.extensionsDir, { recursive: true })
this.initialized = true
return
}
if (!fs.existsSync(this.config.databaseDir)) {
fs.mkdirSync(this.config.databaseDir, { recursive: true })
}
// Discover extensions
const extensionDirs = await this.discoverExtensions()
console.log(`[Extensions] Found ${extensionDirs.length} extension(s)`)
// Load extensions in dependency order
const loadOrder = await this.resolveDependencies(extensionDirs)
for (const extDir of loadOrder) {
try {
await this.loadExtension(extDir)
} catch (e) {
console.error(`[Extensions] Failed to load extension from ${extDir}:`, e)
}
}
this.initialized = true
console.log(`[Extensions] Loaded ${this.extensions.size} extension(s)`)
}
/**
* Discover extension directories
*/
private async discoverExtensions(): Promise<string[]> {
const entries = fs.readdirSync(this.config.extensionsDir, { withFileTypes: true })
const extensionDirs: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const extDir = path.join(this.config.extensionsDir, entry.name)
const indexPath = path.join(extDir, 'index.ts')
const indexJsPath = path.join(extDir, 'index.js')
// Check for index file
if (fs.existsSync(indexPath) || fs.existsSync(indexJsPath)) {
// Check enabled/disabled lists
if (this.config.disabledExtensions?.includes(entry.name)) {
console.log(`[Extensions] Skipping disabled extension: ${entry.name}`)
continue
}
if (this.config.enabledExtensions &&
!this.config.enabledExtensions.includes(entry.name)) {
console.log(`[Extensions] Skipping non-enabled extension: ${entry.name}`)
continue
}
extensionDirs.push(extDir)
}
}
return extensionDirs
}
/**
* Resolve extension dependencies and return load order
*/
private async resolveDependencies(extensionDirs: string[]): Promise<string[]> {
// For now, simple alphabetical order
// TODO: Implement proper dependency resolution with topological sort
return extensionDirs.sort()
}
/**
* Load a single extension
*/
private async loadExtension(extensionDir: string): Promise<void> {
const dirName = path.basename(extensionDir)
console.log(`[Extensions] Loading extension: ${dirName}`)
// Determine index file path
let indexPath = path.join(extensionDir, 'index.js')
if (!fs.existsSync(indexPath)) {
indexPath = path.join(extensionDir, 'index.ts')
}
// Dynamic import
const moduleUrl = `file://${indexPath}`
const module = await import(moduleUrl) as ExtensionModule
if (!module.default) {
throw new Error(`Extension ${dirName} has no default export`)
}
// Instantiate extension
const ExtensionClass = module.default
const instance = new ExtensionClass() as Extension
if (!instance.info) {
throw new Error(`Extension ${dirName} has no info property`)
}
const info = instance.info
// Validate extension ID matches directory name
if (info.id !== dirName) {
console.warn(
`[Extensions] Extension ID '${info.id}' doesn't match directory '${dirName}'`
)
}
// Check for duplicate
if (this.extensions.has(info.id)) {
throw new Error(`Extension ${info.id} already loaded`)
}
// Create isolated database
const database = createExtensionDatabase(info.id, this.config.databaseDir)
// Create context
const context = createExtensionContext(
info,
database,
this.mainHandler,
this.methodRegistry
)
// Track as loading
const loaded: LoadedExtension = {
info,
instance,
database,
status: 'loading',
loadedAt: Date.now()
}
this.extensions.set(info.id, loaded)
this.contexts.set(info.id, context)
try {
// Initialize extension
await instance.initialize(context, database)
loaded.status = 'ready'
console.log(`[Extensions] Extension ${info.id} v${info.version} loaded successfully`)
} catch (e) {
loaded.status = 'error'
loaded.error = e as Error
console.error(`[Extensions] Extension ${info.id} initialization failed:`, e)
throw e
}
}
/**
* Unload a specific extension
*/
async unloadExtension(extensionId: string): Promise<void> {
const loaded = this.extensions.get(extensionId)
if (!loaded) {
throw new Error(`Extension ${extensionId} not found`)
}
console.log(`[Extensions] Unloading extension: ${extensionId}`)
try {
// Call shutdown if available
if (loaded.instance.shutdown) {
await loaded.instance.shutdown()
}
loaded.status = 'stopped'
} catch (e) {
console.error(`[Extensions] Error during ${extensionId} shutdown:`, e)
}
// Close database
if (loaded.database instanceof ExtensionDatabaseImpl) {
loaded.database.close()
}
// Remove registered methods
for (const [name, method] of this.methodRegistry.entries()) {
if (method.extensionId === extensionId) {
this.methodRegistry.delete(name)
}
}
// Remove from maps
this.extensions.delete(extensionId)
this.contexts.delete(extensionId)
}
/**
* Shutdown all extensions
*/
async shutdown(): Promise<void> {
console.log('[Extensions] Shutting down all extensions...')
for (const extensionId of this.extensions.keys()) {
try {
await this.unloadExtension(extensionId)
} catch (e) {
console.error(`[Extensions] Error unloading ${extensionId}:`, e)
}
}
console.log('[Extensions] All extensions shut down')
}
/**
* Get a loaded extension
*/
getExtension(extensionId: string): LoadedExtension | undefined {
return this.extensions.get(extensionId)
}
/**
* Get all loaded extensions
*/
getAllExtensions(): LoadedExtension[] {
return Array.from(this.extensions.values())
}
/**
* Check if an extension is loaded and ready
*/
isReady(extensionId: string): boolean {
const ext = this.extensions.get(extensionId)
return ext?.status === 'ready'
}
/**
* Get all registered RPC methods
*/
getRegisteredMethods(): Map<string, RegisteredMethod> {
return this.methodRegistry
}
/**
* Call an extension RPC method
*/
async callMethod(
methodName: string,
request: any,
applicationId: string,
userPubkey?: string
): Promise<any> {
const method = this.methodRegistry.get(methodName)
if (!method) {
throw new Error(`Unknown method: ${methodName}`)
}
const ext = this.extensions.get(method.extensionId)
if (!ext || ext.status !== 'ready') {
throw new Error(`Extension ${method.extensionId} not ready`)
}
return method.handler(request, applicationId, userPubkey)
}
/**
* Check if a method exists
*/
hasMethod(methodName: string): boolean {
return this.methodRegistry.has(methodName)
}
/**
* Dispatch payment received event to all extensions
*/
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
for (const context of this.contexts.values()) {
try {
await context.dispatchPaymentReceived(payment)
} catch (e) {
console.error('[Extensions] Error dispatching payment:', e)
}
}
}
/**
* Dispatch Nostr event to all extensions
*/
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
for (const context of this.contexts.values()) {
try {
await context.dispatchNostrEvent(event, applicationId)
} catch (e) {
console.error('[Extensions] Error dispatching Nostr event:', e)
}
}
}
/**
* Run health checks on all extensions
*/
async healthCheck(): Promise<Map<string, boolean>> {
const results = new Map<string, boolean>()
for (const [id, ext] of this.extensions.entries()) {
if (ext.status !== 'ready') {
results.set(id, false)
continue
}
try {
if (ext.instance.healthCheck) {
results.set(id, await ext.instance.healthCheck())
} else {
results.set(id, true)
}
} catch (e) {
results.set(id, false)
}
}
return results
}
/**
* Get extension status summary
*/
getStatus(): {
total: number
ready: number
error: number
extensions: Array<{ id: string; name: string; version: string; status: string }>
} {
const extensions = this.getAllExtensions().map(ext => ({
id: ext.info.id,
name: ext.info.name,
version: ext.info.version,
status: ext.status
}))
return {
total: extensions.length,
ready: extensions.filter(e => e.status === 'ready').length,
error: extensions.filter(e => e.status === 'error').length,
extensions
}
}
}
/**
* Create an extension loader instance
*/
export function createExtensionLoader(
config: ExtensionLoaderConfig,
mainHandler: MainHandlerInterface
): ExtensionLoader {
return new ExtensionLoader(config, mainHandler)
}

View file

@ -0,0 +1,155 @@
/**
* MainHandler Adapter for Extension System
*
* Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface
* required by the extension system.
*/
import { MainHandlerInterface } from './context.js'
import { LnurlPayInfo } from './types.js'
import type Main from '../services/main/index.js'
/**
* Create an adapter that wraps mainHandler for extension use
*/
export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface {
return {
applicationManager: {
async getById(id: string) {
// The applicationManager stores apps internally
// We need to access it through the storage layer
try {
const app = await mainHandler.storage.applicationStorage.GetApplication(id)
if (!app) return null
return {
id: app.app_id,
name: app.name,
nostr_public: app.nostr_public_key || '',
balance: app.owner?.balance_sats || 0
}
} catch (e) {
// GetApplication throws if not found
return null
}
},
async PayAppUserInvoice(appId, req) {
return mainHandler.applicationManager.PayAppUserInvoice(appId, req)
}
},
paymentManager: {
async createInvoice(params: {
applicationId: string
amountSats: number
memo?: string
expiry?: number
metadata?: Record<string, any>
}) {
// Get the app to find the user ID
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
if (!app) {
throw new Error(`Application not found: ${params.applicationId}`)
}
// Create invoice using the app owner's user ID
const result = await mainHandler.paymentManager.NewInvoice(
app.owner.user_id,
{
amountSats: params.amountSats,
memo: params.memo || ''
},
{
expiry: params.expiry || 3600
}
)
return {
id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present
paymentRequest: result.invoice,
paymentHash: '', // Not directly available from NewInvoice response
expiry: Date.now() + (params.expiry || 3600) * 1000
}
},
async payInvoice(params: {
applicationId: string
paymentRequest: string
maxFeeSats?: number
userPubkey?: string
}) {
// Get the app to find the user ID and app reference
const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId)
if (!app) {
throw new Error(`Application not found: ${params.applicationId}`)
}
if (params.userPubkey) {
// Resolve the Nostr user's ApplicationUser to get their identifier
const appUser = await mainHandler.storage.applicationStorage.GetOrCreateNostrAppUser(app, params.userPubkey)
console.log(`[MainHandlerAdapter] Paying via PayAppUserInvoice from Nostr user ${params.userPubkey.slice(0, 8)}... (identifier: ${appUser.identifier})`)
// Use applicationManager.PayAppUserInvoice so notifyAppUserPayment fires
// This sends LiveUserOperation events via Nostr for real-time balance updates
const result = await mainHandler.applicationManager.PayAppUserInvoice(
params.applicationId,
{
invoice: params.paymentRequest,
amount: 0, // Use invoice amount
user_identifier: appUser.identifier
}
)
return {
paymentHash: result.preimage || '',
feeSats: result.network_fee || 0
}
}
// Fallback: pay from app owner's balance (no Nostr user context)
const result = await mainHandler.paymentManager.PayInvoice(
app.owner.user_id,
{
invoice: params.paymentRequest,
amount: 0
},
app,
{}
)
return {
paymentHash: result.preimage || '',
feeSats: result.network_fee || 0
}
},
async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
metadata?: string
description?: string
}): Promise<LnurlPayInfo> {
// This would need implementation based on how Lightning.Pub handles LNURL-pay
// For now, throw not implemented
throw new Error('getLnurlPayInfoByPubkey not yet implemented')
}
},
async sendNostrEvent(event: any): Promise<string | null> {
// The mainHandler doesn't directly expose nostrSend
// This would need to be implemented through the nostrMiddleware
// For now, return null (not implemented)
console.warn('[MainHandlerAdapter] sendNostrEvent not fully implemented')
return null
},
async sendEncryptedDM(
applicationId: string,
recipientPubkey: string,
content: string
): Promise<string> {
// This would need implementation using NIP-44 encryption
// For now, throw not implemented
throw new Error('sendEncryptedDM not yet implemented')
}
}
}

255
src/extensions/types.ts Normal file
View file

@ -0,0 +1,255 @@
/**
* Extension System Core Types
*
* These types define the contract between Lightning.Pub and extensions.
*/
/**
* Extension metadata
*/
export interface ExtensionInfo {
id: string // Unique identifier (lowercase, no spaces)
name: string // Display name
version: string // Semver version
description: string // Short description
author: string // Author name or organization
minPubVersion?: string // Minimum Lightning.Pub version required
dependencies?: string[] // Other extension IDs this depends on
}
/**
* Extension database interface
* Provides isolated database access for each extension
*/
export interface ExtensionDatabase {
/**
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
*/
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
/**
* Execute a read query (SELECT)
*/
query<T = any>(sql: string, params?: any[]): Promise<T[]>
/**
* Execute multiple statements in a transaction
*/
transaction<T>(fn: () => Promise<T>): Promise<T>
}
/**
* Application info provided to extensions
*/
export interface ApplicationInfo {
id: string
name: string
nostr_public: string // Application's Nostr pubkey (hex)
balance_sats: number
}
/**
* Invoice creation options
*/
export interface CreateInvoiceOptions {
memo?: string
expiry?: number // Seconds until expiry
metadata?: Record<string, any> // Custom metadata for callbacks
}
/**
* Created invoice result
*/
export interface CreatedInvoice {
id: string // Internal invoice ID
paymentRequest: string // BOLT11 invoice string
paymentHash: string // Payment hash (hex)
expiry: number // Expiry timestamp
}
/**
* Payment received callback data
*/
export interface PaymentReceivedData {
invoiceId: string
paymentHash: string
amountSats: number
metadata?: Record<string, any>
}
/**
* LNURL-pay info response (LUD-06/LUD-16)
* Used for Lightning Address and zap support
*/
export interface LnurlPayInfo {
tag: 'payRequest'
callback: string // URL to call with amount
minSendable: number // Minimum msats
maxSendable: number // Maximum msats
metadata: string // JSON-encoded metadata array
allowsNostr?: boolean // Whether zaps are supported
nostrPubkey?: string // Pubkey for zap receipts (hex)
}
/**
* Nostr event structure (minimal)
*/
export interface NostrEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig?: string
}
/**
* Unsigned Nostr event for publishing
*/
export interface UnsignedNostrEvent {
kind: number
pubkey: string
created_at: number
tags: string[][]
content: string
}
/**
* RPC method handler function
*/
export type RpcMethodHandler = (
request: any,
applicationId: string,
userPubkey?: string
) => Promise<any>
/**
* Extension context - interface provided to extensions for interacting with Lightning.Pub
*/
export interface ExtensionContext {
/**
* Get information about an application
*/
getApplication(applicationId: string): Promise<ApplicationInfo | null>
/**
* Create a Lightning invoice
*/
createInvoice(amountSats: number, options?: CreateInvoiceOptions): Promise<CreatedInvoice>
/**
* Pay a Lightning invoice (requires sufficient balance)
* If userPubkey is provided, pays from that user's balance instead of app.owner
*/
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number, userPubkey?: string): Promise<{
paymentHash: string
feeSats: number
}>
/**
* Send an encrypted DM via Nostr (NIP-44)
*/
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
/**
* Publish a Nostr event (signed by application's key)
*/
publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null>
/**
* Get LNURL-pay info for a user (by pubkey)
* Used to enable Lightning Address support (LUD-16) and zaps (NIP-57)
*/
getLnurlPayInfo(pubkeyHex: string, options?: {
metadata?: string // Custom metadata JSON
description?: string // Human-readable description
}): Promise<LnurlPayInfo>
/**
* Subscribe to payment received callbacks
*/
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void
/**
* Subscribe to incoming Nostr events for the application
*/
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void
/**
* Register an RPC method
*/
registerMethod(name: string, handler: RpcMethodHandler): void
/**
* Get the extension's isolated database
*/
getDatabase(): ExtensionDatabase
/**
* Log a message (prefixed with extension ID)
*/
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void
}
/**
* Extension interface - what extensions must implement
*/
export interface Extension {
/**
* Extension metadata
*/
readonly info: ExtensionInfo
/**
* Initialize the extension
* Called once when the extension is loaded
*/
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
/**
* Shutdown the extension
* Called when Lightning.Pub is shutting down
*/
shutdown?(): Promise<void>
/**
* Health check
* Return true if extension is healthy
*/
healthCheck?(): Promise<boolean>
}
/**
* Extension constructor type
*/
export type ExtensionConstructor = new () => Extension
/**
* Extension module default export
*/
export interface ExtensionModule {
default: ExtensionConstructor
}
/**
* Loaded extension state
*/
export interface LoadedExtension {
info: ExtensionInfo
instance: Extension
database: ExtensionDatabase
status: 'loading' | 'ready' | 'error' | 'stopped'
error?: Error
loadedAt: number
}
/**
* Extension loader configuration
*/
export interface ExtensionLoaderConfig {
extensionsDir: string // Directory containing extensions
databaseDir: string // Directory for extension databases
enabledExtensions?: string[] // If set, only load these extensions
disabledExtensions?: string[] // Extensions to skip
}

View file

@ -0,0 +1,383 @@
/**
* LNURL-withdraw Extension for Lightning.Pub
*
* Implements LUD-03 (LNURL-withdraw) for creating withdraw links
* that allow anyone to pull funds from a Lightning wallet.
*
* Use cases:
* - Quick vouchers (batch single-use codes)
* - Faucets
* - Gift cards / prepaid cards
* - Tips / donations
*/
import {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
HttpRoute,
HttpRequest,
HttpResponse
} from './types.js'
import { runMigrations } from './migrations.js'
import { WithdrawManager } from './managers/withdrawManager.js'
/**
* LNURL-withdraw Extension
*/
export default class WithdrawExtension implements Extension {
readonly info: ExtensionInfo = {
id: 'withdraw',
name: 'LNURL Withdraw',
version: '1.0.0',
description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)',
author: 'Lightning.Pub',
minPubVersion: '1.0.0'
}
private manager!: WithdrawManager
private baseUrl: string = ''
/**
* Initialize the extension
*/
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
// Run migrations
await runMigrations(db)
// Initialize manager
this.manager = new WithdrawManager(db, ctx)
// Register RPC methods
this.registerRpcMethods(ctx)
// Register HTTP routes for LNURL protocol
this.registerHttpRoutes(ctx)
ctx.log('info', 'Extension initialized')
}
/**
* Shutdown the extension
*/
async shutdown(): Promise<void> {
// Cleanup if needed
}
/**
* Set the base URL for LNURL generation
* This should be called by the main application after loading
*/
setBaseUrl(url: string): void {
this.baseUrl = url
this.manager.setBaseUrl(url)
}
/**
* Get HTTP routes for this extension
* These need to be mounted by the main HTTP server
*/
getHttpRoutes(): HttpRoute[] {
return [
// Create withdraw link (HTTP API for ATM/external integrations)
{
method: 'POST',
path: '/api/v1/withdraw/create',
handler: this.handleCreateWithdrawLink.bind(this)
},
// LNURL callback (user submits invoice) - MUST be before :unique_hash routes
{
method: 'GET',
path: '/api/v1/lnurl/cb/:unique_hash',
handler: this.handleLnurlCallback.bind(this)
},
// Initial LNURL request (unique link with use hash)
{
method: 'GET',
path: '/api/v1/lnurl/:unique_hash/:id_unique_hash',
handler: this.handleLnurlUniqueRequest.bind(this)
},
// Initial LNURL request (simple link) - MUST be last (catches all)
{
method: 'GET',
path: '/api/v1/lnurl/:unique_hash',
handler: this.handleLnurlRequest.bind(this)
}
]
}
/**
* Register RPC methods with the extension context
*/
private registerRpcMethods(ctx: ExtensionContext): void {
// Create withdraw link
ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => {
const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey)
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// Create quick vouchers
ctx.registerMethod('withdraw.createVouchers', async (req, appId) => {
const vouchers = await this.manager.createVouchers(
appId,
req.title,
req.amount,
req.count,
req.description
)
return {
vouchers,
total_amount_sats: req.amount * req.count
}
})
// Get withdraw link
ctx.registerMethod('withdraw.getLink', async (req, appId) => {
const link = await this.manager.get(req.id, appId)
if (!link) throw new Error('Withdraw link not found')
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// List withdraw links
ctx.registerMethod('withdraw.listLinks', async (req, appId) => {
const links = await this.manager.list(
appId,
req.include_spent || false,
req.limit,
req.offset
)
return { links }
})
// Update withdraw link
ctx.registerMethod('withdraw.updateLink', async (req, appId) => {
const link = await this.manager.update(req.id, appId, req as UpdateWithdrawLinkRequest)
if (!link) throw new Error('Withdraw link not found')
const stats = await this.manager.getWithdrawalStats(link.id)
return {
link,
total_withdrawn_sats: stats.total_sats,
withdrawals_count: stats.count
}
})
// Delete withdraw link
ctx.registerMethod('withdraw.deleteLink', async (req, appId) => {
const success = await this.manager.delete(req.id, appId)
if (!success) throw new Error('Withdraw link not found')
return { success }
})
// List withdrawals
ctx.registerMethod('withdraw.listWithdrawals', async (req, appId) => {
const withdrawals = await this.manager.listWithdrawals(
appId,
req.link_id,
req.limit,
req.offset
)
return { withdrawals }
})
// Get withdrawal stats
ctx.registerMethod('withdraw.getStats', async (req, appId) => {
// Get all links to calculate total stats
const links = await this.manager.list(appId, true)
let totalLinks = links.length
let activeLinks = 0
let spentLinks = 0
let totalWithdrawn = 0
let totalWithdrawals = 0
for (const link of links) {
if (link.used >= link.uses) {
spentLinks++
} else {
activeLinks++
}
const stats = await this.manager.getWithdrawalStats(link.id)
totalWithdrawn += stats.total_sats
totalWithdrawals += stats.count
}
return {
total_links: totalLinks,
active_links: activeLinks,
spent_links: spentLinks,
total_withdrawn_sats: totalWithdrawn,
total_withdrawals: totalWithdrawals
}
})
}
/**
* Register HTTP routes (called by extension context)
*/
private registerHttpRoutes(ctx: ExtensionContext): void {
// HTTP routes are exposed via getHttpRoutes()
// The main application is responsible for mounting them
ctx.log('debug', 'HTTP routes registered for LNURL protocol')
}
// =========================================================================
// HTTP Route Handlers
// =========================================================================
/**
* Handle create withdraw link request (HTTP API)
* POST /api/v1/withdraw/create
*
* Body: {
* title: string
* min_withdrawable: number (sats)
* max_withdrawable: number (sats)
* uses?: number (defaults to 1)
* wait_time?: number (seconds between uses, defaults to 0)
* }
*
* Auth: Bearer token in Authorization header (app_<app_id>)
*
* Returns: {
* link: { lnurl, unique_hash, id, ... }
* }
*/
private async handleCreateWithdrawLink(req: HttpRequest): Promise<HttpResponse> {
try {
const { title, min_withdrawable, max_withdrawable, uses, wait_time } = req.body
// Extract app_id from Authorization header (Bearer app_<app_id>)
const authHeader = req.headers?.authorization || req.headers?.Authorization || ''
let app_id = 'default'
if (authHeader.startsWith('Bearer app_')) {
app_id = authHeader.replace('Bearer app_', '')
}
if (!title || !min_withdrawable) {
return {
status: 400,
body: { status: 'ERROR', reason: 'Missing required fields: title, min_withdrawable' },
headers: { 'Content-Type': 'application/json' }
}
}
const link = await this.manager.create(app_id, {
title,
min_withdrawable,
max_withdrawable: max_withdrawable || min_withdrawable,
uses: uses || 1,
wait_time: wait_time || 0,
is_unique: false // Simple single-use links for ATM
})
// Return in format expected by ATM client
return {
status: 200,
body: {
status: 'OK',
link: {
lnurl: link.lnurl,
unique_hash: link.unique_hash,
id: link.id,
title: link.title,
min_withdrawable: link.min_withdrawable,
max_withdrawable: link.max_withdrawable,
uses: link.uses,
used: link.used
}
},
headers: { 'Content-Type': 'application/json' }
}
} catch (error: any) {
return {
status: 500,
body: { status: 'ERROR', reason: error.message },
headers: { 'Content-Type': 'application/json' }
}
}
}
/**
* Handle initial LNURL request (simple link)
* GET /api/v1/lnurl/:unique_hash
*/
private async handleLnurlRequest(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash } = req.params
const result = await this.manager.handleLnurlRequest(unique_hash)
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
/**
* Handle initial LNURL request (unique link)
* GET /api/v1/lnurl/:unique_hash/:id_unique_hash
*/
private async handleLnurlUniqueRequest(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash, id_unique_hash } = req.params
const result = await this.manager.handleLnurlRequest(unique_hash, id_unique_hash)
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
/**
* Handle LNURL callback (user submits invoice)
* GET /api/v1/lnurl/cb/:unique_hash?k1=...&pr=...&id_unique_hash=...
*/
private async handleLnurlCallback(req: HttpRequest): Promise<HttpResponse> {
const { unique_hash } = req.params
const { k1, pr, id_unique_hash } = req.query
if (!k1 || !pr) {
return {
status: 200,
body: { status: 'ERROR', reason: 'Missing k1 or pr parameter' },
headers: { 'Content-Type': 'application/json' }
}
}
const result = await this.manager.handleLnurlCallback(unique_hash, {
k1,
pr,
id_unique_hash
})
return {
status: 200,
body: result,
headers: {
'Content-Type': 'application/json'
}
}
}
}
// Export types for external use
export * from './types.js'
export { WithdrawManager } from './managers/withdrawManager.js'

View file

@ -0,0 +1,717 @@
/**
* Withdraw Link Manager
*
* Handles CRUD operations for withdraw links and processes withdrawals
*/
import {
ExtensionContext,
ExtensionDatabase,
WithdrawLink,
Withdrawal,
CreateWithdrawLinkRequest,
UpdateWithdrawLinkRequest,
WithdrawLinkWithLnurl,
LnurlWithdrawResponse,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlCallbackParams
} from '../types.js'
import {
generateId,
generateK1,
generateUniqueHash,
generateUseHash,
verifyUseHash,
encodeLnurl,
buildLnurlUrl,
buildUniqueLnurlUrl,
buildCallbackUrl,
satsToMsats
} from '../utils/lnurl.js'
/**
* Database row types
*/
interface WithdrawLinkRow {
id: string
application_id: string
title: string
description: string | null
min_withdrawable: number
max_withdrawable: number
uses: number
used: number
wait_time: number
unique_hash: string
k1: string
is_unique: number
uses_csv: string
open_time: number
creator_pubkey: string | null
webhook_url: string | null
webhook_headers: string | null
webhook_body: string | null
created_at: number
updated_at: number
}
interface WithdrawalRow {
id: string
link_id: string
application_id: string
payment_hash: string
amount_sats: number
fee_sats: number
recipient_node: string | null
webhook_success: number | null
webhook_response: string | null
created_at: number
}
/**
* Convert row to WithdrawLink
*/
function rowToLink(row: WithdrawLinkRow): WithdrawLink {
return {
id: row.id,
application_id: row.application_id,
title: row.title,
description: row.description || undefined,
min_withdrawable: row.min_withdrawable,
max_withdrawable: row.max_withdrawable,
uses: row.uses,
used: row.used,
wait_time: row.wait_time,
unique_hash: row.unique_hash,
k1: row.k1,
is_unique: row.is_unique === 1,
uses_csv: row.uses_csv,
open_time: row.open_time,
creator_pubkey: row.creator_pubkey || undefined,
webhook_url: row.webhook_url || undefined,
webhook_headers: row.webhook_headers || undefined,
webhook_body: row.webhook_body || undefined,
created_at: row.created_at,
updated_at: row.updated_at
}
}
/**
* Convert row to Withdrawal
*/
function rowToWithdrawal(row: WithdrawalRow): Withdrawal {
return {
id: row.id,
link_id: row.link_id,
application_id: row.application_id,
payment_hash: row.payment_hash,
amount_sats: row.amount_sats,
fee_sats: row.fee_sats,
recipient_node: row.recipient_node || undefined,
webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1,
webhook_response: row.webhook_response || undefined,
created_at: row.created_at
}
}
/**
* WithdrawManager - Handles withdraw link operations
*/
export class WithdrawManager {
private baseUrl: string = ''
constructor(
private db: ExtensionDatabase,
private ctx: ExtensionContext
) {}
/**
* Set the base URL for LNURL generation
*/
setBaseUrl(url: string): void {
this.baseUrl = url.replace(/\/$/, '')
}
/**
* Add LNURL to a withdraw link
*/
private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl {
const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash)
return {
...link,
lnurl: encodeLnurl(lnurlUrl),
lnurl_url: lnurlUrl
}
}
// =========================================================================
// CRUD Operations
// =========================================================================
/**
* Create a new withdraw link
*/
async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise<WithdrawLinkWithLnurl> {
// Validation
if (req.uses < 1 || req.uses > 250) {
throw new Error('Uses must be between 1 and 250')
}
if (req.min_withdrawable < 1) {
throw new Error('Min withdrawable must be at least 1 sat')
}
if (req.max_withdrawable < req.min_withdrawable) {
throw new Error('Max withdrawable must be >= min withdrawable')
}
if (req.wait_time < 0) {
throw new Error('Wait time cannot be negative')
}
// Validate webhook JSON if provided
if (req.webhook_headers) {
try {
JSON.parse(req.webhook_headers)
} catch {
throw new Error('webhook_headers must be valid JSON')
}
}
if (req.webhook_body) {
try {
JSON.parse(req.webhook_body)
} catch {
throw new Error('webhook_body must be valid JSON')
}
}
const now = Math.floor(Date.now() / 1000)
const id = generateId()
const usesCsv = Array.from({ length: req.uses }, (_, i) => String(i)).join(',')
const link: WithdrawLink = {
id,
application_id: applicationId,
title: req.title.trim(),
description: req.description?.trim(),
min_withdrawable: req.min_withdrawable,
max_withdrawable: req.max_withdrawable,
uses: req.uses,
used: 0,
wait_time: req.wait_time,
unique_hash: generateUniqueHash(),
k1: generateK1(),
is_unique: req.is_unique || false,
uses_csv: usesCsv,
open_time: now,
creator_pubkey: creatorPubkey,
webhook_url: req.webhook_url,
webhook_headers: req.webhook_headers,
webhook_body: req.webhook_body,
created_at: now,
updated_at: now
}
await this.db.execute(
`INSERT INTO withdraw_links (
id, application_id, title, description,
min_withdrawable, max_withdrawable, uses, used, wait_time,
unique_hash, k1, is_unique, uses_csv, open_time,
creator_pubkey,
webhook_url, webhook_headers, webhook_body,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
link.id, link.application_id, link.title, link.description || null,
link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time,
link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time,
link.creator_pubkey || null,
link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null,
link.created_at, link.updated_at
]
)
return this.addLnurl(link)
}
/**
* Create multiple vouchers (single-use withdraw links)
*/
async createVouchers(
applicationId: string,
title: string,
amount: number,
count: number,
description?: string
): Promise<WithdrawLinkWithLnurl[]> {
if (count < 1 || count > 100) {
throw new Error('Count must be between 1 and 100')
}
if (amount < 1) {
throw new Error('Amount must be at least 1 sat')
}
const vouchers: WithdrawLinkWithLnurl[] = []
for (let i = 0; i < count; i++) {
const voucher = await this.create(applicationId, {
title: `${title} #${i + 1}`,
description,
min_withdrawable: amount,
max_withdrawable: amount,
uses: 1,
wait_time: 0,
is_unique: false
})
vouchers.push(voucher)
}
return vouchers
}
/**
* Get a withdraw link by ID
*/
async get(id: string, applicationId: string): Promise<WithdrawLinkWithLnurl | null> {
const rows = await this.db.query<WithdrawLinkRow>(
'SELECT * FROM withdraw_links WHERE id = ? AND application_id = ?',
[id, applicationId]
)
if (rows.length === 0) return null
return this.addLnurl(rowToLink(rows[0]))
}
/**
* Get a withdraw link by unique hash (for LNURL)
*/
async getByHash(uniqueHash: string): Promise<WithdrawLink | null> {
const rows = await this.db.query<WithdrawLinkRow>(
'SELECT * FROM withdraw_links WHERE unique_hash = ?',
[uniqueHash]
)
if (rows.length === 0) return null
return rowToLink(rows[0])
}
/**
* List withdraw links for an application
*/
async list(
applicationId: string,
includeSpent: boolean = false,
limit?: number,
offset?: number
): Promise<WithdrawLinkWithLnurl[]> {
let sql = 'SELECT * FROM withdraw_links WHERE application_id = ?'
const params: any[] = [applicationId]
if (!includeSpent) {
sql += ' AND used < uses'
}
sql += ' ORDER BY created_at DESC'
if (limit) {
sql += ' LIMIT ?'
params.push(limit)
if (offset) {
sql += ' OFFSET ?'
params.push(offset)
}
}
const rows = await this.db.query<WithdrawLinkRow>(sql, params)
return rows.map(row => this.addLnurl(rowToLink(row)))
}
/**
* Update a withdraw link
*/
async update(
id: string,
applicationId: string,
req: UpdateWithdrawLinkRequest
): Promise<WithdrawLinkWithLnurl | null> {
const existing = await this.get(id, applicationId)
if (!existing) return null
// Validation
if (req.uses !== undefined) {
if (req.uses < 1 || req.uses > 250) {
throw new Error('Uses must be between 1 and 250')
}
if (req.uses < existing.used) {
throw new Error('Cannot reduce uses below current used count')
}
}
const minWith = req.min_withdrawable ?? existing.min_withdrawable
const maxWith = req.max_withdrawable ?? existing.max_withdrawable
if (minWith < 1) {
throw new Error('Min withdrawable must be at least 1 sat')
}
if (maxWith < minWith) {
throw new Error('Max withdrawable must be >= min withdrawable')
}
// Handle uses change
let usesCsv = existing.uses_csv
const newUses = req.uses ?? existing.uses
if (newUses !== existing.uses) {
const currentUses = usesCsv.split(',').filter(u => u !== '')
if (newUses > existing.uses) {
// Add more uses
const lastNum = currentUses.length > 0 ? parseInt(currentUses[currentUses.length - 1], 10) : -1
for (let i = lastNum + 1; currentUses.length < (newUses - existing.used); i++) {
currentUses.push(String(i))
}
} else {
// Remove uses (keep first N)
usesCsv = currentUses.slice(0, newUses - existing.used).join(',')
}
usesCsv = currentUses.join(',')
}
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
`UPDATE withdraw_links SET
title = ?, description = ?,
min_withdrawable = ?, max_withdrawable = ?,
uses = ?, wait_time = ?, is_unique = ?, uses_csv = ?,
webhook_url = ?, webhook_headers = ?, webhook_body = ?,
updated_at = ?
WHERE id = ? AND application_id = ?`,
[
req.title ?? existing.title,
req.description ?? existing.description ?? null,
minWith, maxWith,
newUses,
req.wait_time ?? existing.wait_time,
(req.is_unique ?? existing.is_unique) ? 1 : 0,
usesCsv,
req.webhook_url ?? existing.webhook_url ?? null,
req.webhook_headers ?? existing.webhook_headers ?? null,
req.webhook_body ?? existing.webhook_body ?? null,
now,
id, applicationId
]
)
return this.get(id, applicationId)
}
/**
* Delete a withdraw link
*/
async delete(id: string, applicationId: string): Promise<boolean> {
const result = await this.db.execute(
'DELETE FROM withdraw_links WHERE id = ? AND application_id = ?',
[id, applicationId]
)
return (result.changes || 0) > 0
}
// =========================================================================
// LNURL Protocol Handlers
// =========================================================================
/**
* Handle initial LNURL request (user scans QR)
* Returns withdraw parameters
*/
async handleLnurlRequest(
uniqueHash: string,
idUniqueHash?: string
): Promise<LnurlWithdrawResponse | LnurlErrorResponse> {
const link = await this.getByHash(uniqueHash)
if (!link) {
return { status: 'ERROR', reason: 'Withdraw link does not exist.' }
}
if (link.used >= link.uses) {
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
}
// For unique links, require id_unique_hash
if (link.is_unique && !idUniqueHash) {
return { status: 'ERROR', reason: 'This link requires a unique hash.' }
}
// Verify unique hash if provided
if (idUniqueHash) {
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, idUniqueHash)
if (!useNumber) {
return { status: 'ERROR', reason: 'Invalid unique hash.' }
}
}
const callbackUrl = buildCallbackUrl(this.baseUrl, link.unique_hash)
return {
tag: 'withdrawRequest',
callback: idUniqueHash ? `${callbackUrl}?id_unique_hash=${idUniqueHash}` : callbackUrl,
k1: link.k1,
minWithdrawable: satsToMsats(link.min_withdrawable),
maxWithdrawable: satsToMsats(link.max_withdrawable),
defaultDescription: link.title
}
}
/**
* Handle LNURL callback (user submits invoice)
* Pays the invoice and records the withdrawal
*/
async handleLnurlCallback(
uniqueHash: string,
params: LnurlCallbackParams
): Promise<LnurlSuccessResponse | LnurlErrorResponse> {
const link = await this.getByHash(uniqueHash)
if (!link) {
return { status: 'ERROR', reason: 'Withdraw link not found.' }
}
if (link.used >= link.uses) {
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
}
if (link.k1 !== params.k1) {
return { status: 'ERROR', reason: 'Invalid k1.' }
}
// Check wait time
const now = Math.floor(Date.now() / 1000)
if (now < link.open_time) {
const waitSecs = link.open_time - now
return { status: 'ERROR', reason: `Please wait ${waitSecs} seconds.` }
}
// For unique links, verify and consume the use hash
if (params.id_unique_hash) {
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, params.id_unique_hash)
if (!useNumber) {
return { status: 'ERROR', reason: 'Invalid unique hash.' }
}
} else if (link.is_unique) {
return { status: 'ERROR', reason: 'Unique hash required.' }
}
// Prevent double-spending with hash check
try {
await this.createHashCheck(params.id_unique_hash || uniqueHash, params.k1)
} catch {
return { status: 'ERROR', reason: 'Withdrawal already in progress.' }
}
try {
// Pay the invoice from the creator's balance (if created via Nostr RPC)
const payment = await this.ctx.payInvoice(
link.application_id,
params.pr,
link.max_withdrawable,
link.creator_pubkey
)
// Record the withdrawal
await this.recordWithdrawal(link, payment.paymentHash, link.max_withdrawable, payment.feeSats)
// Increment usage
await this.incrementUsage(link, params.id_unique_hash)
// Clean up hash check
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
// Dispatch webhook if configured
if (link.webhook_url) {
this.dispatchWebhook(link, payment.paymentHash, params.pr).catch(err => {
console.error('[Withdraw] Webhook error:', err)
})
}
return { status: 'OK' }
} catch (err: any) {
// Clean up hash check on failure
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
return { status: 'ERROR', reason: `Payment failed: ${err.message}` }
}
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Increment link usage and update open_time
*/
private async incrementUsage(link: WithdrawLink, idUniqueHash?: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
let usesCsv = link.uses_csv
// Remove used hash from uses_csv if unique
if (idUniqueHash) {
const uses = usesCsv.split(',').filter(u => {
const hash = generateUseHash(link.id, link.unique_hash, u.trim())
return hash !== idUniqueHash
})
usesCsv = uses.join(',')
}
await this.db.execute(
`UPDATE withdraw_links SET
used = used + 1,
open_time = ?,
uses_csv = ?,
updated_at = ?
WHERE id = ?`,
[now + link.wait_time, usesCsv, now, link.id]
)
}
/**
* Record a successful withdrawal
*/
private async recordWithdrawal(
link: WithdrawLink,
paymentHash: string,
amountSats: number,
feeSats: number
): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
`INSERT INTO withdrawals (
id, link_id, application_id,
payment_hash, amount_sats, fee_sats,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
generateId(),
link.id,
link.application_id,
paymentHash,
amountSats,
feeSats,
now
]
)
}
/**
* Create hash check to prevent double-spending
*/
private async createHashCheck(hash: string, k1: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await this.db.execute(
'INSERT INTO hash_checks (hash, k1, created_at) VALUES (?, ?, ?)',
[hash, k1, now]
)
}
/**
* Delete hash check after completion
*/
private async deleteHashCheck(hash: string): Promise<void> {
await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash])
}
/**
* List withdrawals
*/
async listWithdrawals(
applicationId: string,
linkId?: string,
limit?: number,
offset?: number
): Promise<Withdrawal[]> {
let sql = 'SELECT * FROM withdrawals WHERE application_id = ?'
const params: any[] = [applicationId]
if (linkId) {
sql += ' AND link_id = ?'
params.push(linkId)
}
sql += ' ORDER BY created_at DESC'
if (limit) {
sql += ' LIMIT ?'
params.push(limit)
if (offset) {
sql += ' OFFSET ?'
params.push(offset)
}
}
const rows = await this.db.query<WithdrawalRow>(sql, params)
return rows.map(rowToWithdrawal)
}
/**
* Get withdrawal stats for a link
*/
async getWithdrawalStats(linkId: string): Promise<{ total_sats: number; count: number }> {
const result = await this.db.query<{ total: number; count: number }>(
`SELECT COALESCE(SUM(amount_sats), 0) as total, COUNT(*) as count
FROM withdrawals WHERE link_id = ?`,
[linkId]
)
return {
total_sats: result[0]?.total || 0,
count: result[0]?.count || 0
}
}
/**
* Dispatch webhook notification
*/
private async dispatchWebhook(
link: WithdrawLink,
paymentHash: string,
paymentRequest: string
): Promise<void> {
if (!link.webhook_url) return
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (link.webhook_headers) {
Object.assign(headers, JSON.parse(link.webhook_headers))
}
const body = {
payment_hash: paymentHash,
payment_request: paymentRequest,
lnurlw: link.id,
body: link.webhook_body ? JSON.parse(link.webhook_body) : {}
}
const response = await fetch(link.webhook_url, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
// Update withdrawal record with webhook result
await this.db.execute(
`UPDATE withdrawals SET
webhook_success = ?,
webhook_response = ?
WHERE payment_hash = ?`,
[response.ok ? 1 : 0, await response.text(), paymentHash]
)
} catch (err: any) {
await this.db.execute(
`UPDATE withdrawals SET
webhook_success = 0,
webhook_response = ?
WHERE payment_hash = ?`,
[err.message, paymentHash]
)
}
}
}

View file

@ -0,0 +1,164 @@
/**
* LNURL-withdraw Extension Database Migrations
*/
import { ExtensionDatabase } from '../types.js'
export interface Migration {
version: number
name: string
up: (db: ExtensionDatabase) => Promise<void>
down?: (db: ExtensionDatabase) => Promise<void>
}
export const migrations: Migration[] = [
{
version: 1,
name: 'create_withdraw_links_table',
up: async (db: ExtensionDatabase) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS withdraw_links (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
-- Display
title TEXT NOT NULL,
description TEXT,
-- Amounts (sats)
min_withdrawable INTEGER NOT NULL,
max_withdrawable INTEGER NOT NULL,
-- Usage limits
uses INTEGER NOT NULL DEFAULT 1,
used INTEGER NOT NULL DEFAULT 0,
wait_time INTEGER NOT NULL DEFAULT 0,
-- Security
unique_hash TEXT NOT NULL UNIQUE,
k1 TEXT NOT NULL,
is_unique INTEGER NOT NULL DEFAULT 0,
uses_csv TEXT NOT NULL DEFAULT '',
-- Rate limiting
open_time INTEGER NOT NULL DEFAULT 0,
-- Webhooks
webhook_url TEXT,
webhook_headers TEXT,
webhook_body TEXT,
-- Timestamps
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
// Index for looking up by unique_hash (LNURL)
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdraw_links_unique_hash
ON withdraw_links(unique_hash)
`)
// Index for listing by application
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdraw_links_application
ON withdraw_links(application_id, created_at DESC)
`)
}
},
{
version: 2,
name: 'create_withdrawals_table',
up: async (db: ExtensionDatabase) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS withdrawals (
id TEXT PRIMARY KEY,
link_id TEXT NOT NULL,
application_id TEXT NOT NULL,
-- Payment details
payment_hash TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
-- Recipient
recipient_node TEXT,
-- Webhook result
webhook_success INTEGER,
webhook_response TEXT,
-- Timestamp
created_at INTEGER NOT NULL,
FOREIGN KEY (link_id) REFERENCES withdraw_links(id) ON DELETE CASCADE
)
`)
// Index for listing withdrawals by link
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdrawals_link
ON withdrawals(link_id, created_at DESC)
`)
// Index for looking up by payment hash
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_withdrawals_payment_hash
ON withdrawals(payment_hash)
`)
}
},
{
version: 3,
name: 'create_hash_checks_table',
up: async (db: ExtensionDatabase) => {
// Temporary table to prevent double-spending during payment processing
await db.execute(`
CREATE TABLE IF NOT EXISTS hash_checks (
hash TEXT PRIMARY KEY,
k1 TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`)
}
},
{
version: 4,
name: 'add_creator_pubkey_column',
up: async (db: ExtensionDatabase) => {
// Store the Nostr pubkey of the user who created the withdraw link
// so that when the LNURL callback fires, we debit the correct user's balance
await db.execute(`
ALTER TABLE withdraw_links ADD COLUMN creator_pubkey TEXT
`)
}
}
]
/**
* Run all pending migrations
*/
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
// Get current version
const versionResult = await db.query<{ value: string }>(
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
).catch(() => [])
const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0
// Run pending migrations
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[Withdraw] Running migration ${migration.version}: ${migration.name}`)
await migration.up(db)
// Update version
await db.execute(
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[String(migration.version)]
)
}
}
}

View file

@ -0,0 +1,264 @@
/**
* LNURL-withdraw Extension Types
* Implements LUD-03 (LNURL-withdraw) for Lightning.Pub
*/
// Re-export base extension types
export {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
ApplicationInfo,
RpcMethodHandler
} from '../types.js'
// ============================================================================
// Core Data Types
// ============================================================================
/**
* A withdraw link that can be used to pull funds
*/
export interface WithdrawLink {
id: string
application_id: string
// Display
title: string
description?: string
// Amounts (in sats)
min_withdrawable: number
max_withdrawable: number
// Usage limits
uses: number // Total allowed uses
used: number // Times used so far
wait_time: number // Seconds between uses
// Security
unique_hash: string // For LNURL URL
k1: string // Challenge for callback
is_unique: boolean // Generate unique code per use
uses_csv: string // Comma-separated list of available use IDs
// Rate limiting
open_time: number // Unix timestamp when next use is allowed
// Creator identity (for Nostr RPC-created links)
creator_pubkey?: string // Nostr pubkey of the user who created this link
// Webhook notifications
webhook_url?: string
webhook_headers?: string // JSON string
webhook_body?: string // JSON string
// Timestamps
created_at: number
updated_at: number
}
/**
* Withdrawal record - tracks each successful withdrawal
*/
export interface Withdrawal {
id: string
link_id: string
application_id: string
// Payment details
payment_hash: string
amount_sats: number
fee_sats: number
// Recipient (if known)
recipient_node?: string
// Webhook result
webhook_success?: boolean
webhook_response?: string
// Timestamp
created_at: number
}
/**
* Hash check - prevents double-spending during payment
*/
export interface HashCheck {
hash: string
k1: string
created_at: number
}
// ============================================================================
// LNURL Protocol Types (LUD-03)
// ============================================================================
/**
* LNURL-withdraw response (first call)
* Returned when user scans the QR code
*/
export interface LnurlWithdrawResponse {
tag: 'withdrawRequest'
callback: string // URL to call with invoice
k1: string // Challenge
minWithdrawable: number // Millisats
maxWithdrawable: number // Millisats
defaultDescription: string
}
/**
* LNURL error response
*/
export interface LnurlErrorResponse {
status: 'ERROR'
reason: string
}
/**
* LNURL success response
*/
export interface LnurlSuccessResponse {
status: 'OK'
}
// ============================================================================
// RPC Request/Response Types
// ============================================================================
/**
* Create a new withdraw link
*/
export interface CreateWithdrawLinkRequest {
title: string
description?: string
min_withdrawable: number // sats
max_withdrawable: number // sats
uses: number // 1-250
wait_time: number // seconds between uses
is_unique?: boolean // generate unique code per use
webhook_url?: string
webhook_headers?: string // JSON
webhook_body?: string // JSON
}
/**
* Update an existing withdraw link
*/
export interface UpdateWithdrawLinkRequest {
id: string
title?: string
description?: string
min_withdrawable?: number
max_withdrawable?: number
uses?: number
wait_time?: number
is_unique?: boolean
webhook_url?: string
webhook_headers?: string
webhook_body?: string
}
/**
* Get withdraw link by ID
*/
export interface GetWithdrawLinkRequest {
id: string
}
/**
* List withdraw links
*/
export interface ListWithdrawLinksRequest {
include_spent?: boolean // Include fully used links
limit?: number
offset?: number
}
/**
* Delete withdraw link
*/
export interface DeleteWithdrawLinkRequest {
id: string
}
/**
* Create quick vouchers (batch of single-use links)
*/
export interface CreateVouchersRequest {
title: string
amount: number // sats per voucher
count: number // number of vouchers (1-100)
description?: string
}
/**
* Get withdraw link with LNURL
*/
export interface WithdrawLinkWithLnurl extends WithdrawLink {
lnurl: string // bech32 encoded LNURL
lnurl_url: string // raw callback URL
}
/**
* List withdrawals for a link
*/
export interface ListWithdrawalsRequest {
link_id?: string
limit?: number
offset?: number
}
/**
* Withdraw link response with stats
*/
export interface WithdrawLinkResponse {
link: WithdrawLinkWithLnurl
total_withdrawn_sats: number
withdrawals_count: number
}
/**
* Vouchers response
*/
export interface VouchersResponse {
vouchers: WithdrawLinkWithLnurl[]
total_amount_sats: number
}
// ============================================================================
// HTTP Handler Types
// ============================================================================
/**
* LNURL callback parameters
*/
export interface LnurlCallbackParams {
k1: string // Challenge from initial response
pr: string // Payment request (BOLT11 invoice)
id_unique_hash?: string // For unique links
}
/**
* HTTP route handler
*/
export interface HttpRoute {
method: 'GET' | 'POST'
path: string
handler: (req: HttpRequest) => Promise<HttpResponse>
}
export interface HttpRequest {
params: Record<string, string>
query: Record<string, string>
body?: any
headers: Record<string, string>
}
export interface HttpResponse {
status: number
body: any
headers?: Record<string, string>
}

View file

@ -0,0 +1,131 @@
/**
* LNURL Encoding Utilities
*
* LNURL is a bech32-encoded URL with hrp "lnurl"
* See: https://github.com/lnurl/luds
*/
import { bech32 } from 'bech32'
import crypto from 'crypto'
/**
* Encode a URL as LNURL (bech32)
*/
export function encodeLnurl(url: string): string {
const words = bech32.toWords(Buffer.from(url, 'utf8'))
return bech32.encode('lnurl', words, 2000) // 2000 char limit for URLs
}
/**
* Decode an LNURL to a URL
*/
export function decodeLnurl(lnurl: string): string {
const { prefix, words } = bech32.decode(lnurl, 2000)
if (prefix !== 'lnurl') {
throw new Error('Invalid LNURL prefix')
}
return Buffer.from(bech32.fromWords(words)).toString('utf8')
}
/**
* Generate a URL-safe random ID
*/
export function generateId(length: number = 22): string {
const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4))
return bytes.toString('base64url').slice(0, length)
}
/**
* Generate a k1 challenge (32 bytes hex)
*/
export function generateK1(): string {
return crypto.randomBytes(32).toString('hex')
}
/**
* Generate a unique hash for a link
*/
export function generateUniqueHash(): string {
return generateId(32)
}
/**
* Generate a unique hash for a specific use of a link
* This creates a deterministic hash based on link ID, unique_hash, and use number
*/
export function generateUseHash(linkId: string, uniqueHash: string, useNumber: string): string {
const data = `${linkId}${uniqueHash}${useNumber}`
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
}
/**
* Verify a use hash matches one of the available uses
*/
export function verifyUseHash(
linkId: string,
uniqueHash: string,
usesCsv: string,
providedHash: string
): string | null {
const uses = usesCsv.split(',').filter(u => u.trim() !== '')
for (const useNumber of uses) {
const expectedHash = generateUseHash(linkId, uniqueHash, useNumber.trim())
if (expectedHash === providedHash) {
return useNumber.trim()
}
}
return null
}
/**
* Build the LNURL callback URL for a withdraw link
*/
export function buildLnurlUrl(baseUrl: string, uniqueHash: string): string {
// Remove trailing slash from baseUrl
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/${uniqueHash}`
}
/**
* Build the LNURL callback URL for a unique withdraw link
*/
export function buildUniqueLnurlUrl(
baseUrl: string,
uniqueHash: string,
useHash: string
): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/${uniqueHash}/${useHash}`
}
/**
* Build the callback URL for the second step (where user sends invoice)
*/
export function buildCallbackUrl(baseUrl: string, uniqueHash: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/v1/lnurl/cb/${uniqueHash}`
}
/**
* Sats to millisats
*/
export function satsToMsats(sats: number): number {
return sats * 1000
}
/**
* Millisats to sats
*/
export function msatsToSats(msats: number): number {
return Math.floor(msats / 1000)
}
/**
* Validate a BOLT11 invoice (basic check)
*/
export function isValidBolt11(invoice: string): boolean {
const lower = invoice.toLowerCase()
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
}

View file

@ -1,4 +1,8 @@
import 'dotenv/config' import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import path from 'path'
import { fileURLToPath } from 'url'
import NewServer from '../proto/autogenerated/ts/express_server.js' import NewServer from '../proto/autogenerated/ts/express_server.js'
import GetServerMethods from './services/serverMethods/index.js' import GetServerMethods from './services/serverMethods/index.js'
import serverOptions from './auth.js'; import serverOptions from './auth.js';
@ -8,9 +12,15 @@ import { initMainHandler, initSettings } from './services/main/init.js';
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { LoadStorageSettingsFromEnv } from './services/storage/index.js'; import { LoadStorageSettingsFromEnv } from './services/storage/index.js';
import { AppInfo } from './services/nostr/nostrPool.js'; import { AppInfo } from './services/nostr/nostrPool.js';
import { createExtensionLoader, ExtensionLoader } from './extensions/loader.js'
import { createMainHandlerAdapter } from './extensions/mainHandlerAdapter.js'
import type { HttpRoute } from './extensions/withdraw/types.js'
//@ts-ignore //@ts-ignore
const { nprofileEncode } = nip19 const { nprofileEncode } = nip19
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const start = async () => { const start = async () => {
const log = getLogger({}) const log = getLogger({})
@ -25,6 +35,42 @@ const start = async () => {
const { mainHandler, localProviderClient, wizard, adminManager } = keepOn const { mainHandler, localProviderClient, wizard, adminManager } = keepOn
const serverMethods = GetServerMethods(mainHandler) const serverMethods = GetServerMethods(mainHandler)
// Initialize extension system BEFORE nostrMiddleware so RPC methods are available
let extensionLoader: ExtensionLoader | null = null
const mainPort = settingsManager.getSettings().serviceSettings.servicePort
const extensionPort = mainPort + 1
// Extension routes run on a separate port (main port + 1)
// SERVICE_URL for extensions should point to this port for LNURL to work
// In production, use a reverse proxy to route /api/v1/lnurl/* to extension port
const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}`
try {
log("initializing extension system")
const extensionsDir = path.join(__dirname, 'extensions')
const databaseDir = path.join(__dirname, '..', 'data', 'extensions')
const mainHandlerAdapter = createMainHandlerAdapter(mainHandler)
extensionLoader = createExtensionLoader(
{ extensionsDir, databaseDir },
mainHandlerAdapter
)
await extensionLoader.loadAll()
log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`)
// Set base URL for LNURL generation on withdraw extension
const withdrawExt = extensionLoader.getExtension('withdraw')
if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) {
(withdrawExt.instance as any).setBaseUrl(extensionServiceUrl)
log(`withdraw extension base URL set to ${extensionServiceUrl}`)
}
} catch (e) {
log(`extension system initialization failed: ${e}`)
}
// Initialize nostr middleware with extension loader for RPC routing
log("initializing nostr middleware") log("initializing nostr middleware")
const relays = settingsManager.getSettings().nostrRelaySettings.relays const relays = settingsManager.getSettings().nostrRelaySettings.relays
const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength const maxEventContentLength = settingsManager.getSettings().nostrRelaySettings.maxEventContentLength
@ -45,7 +91,8 @@ const start = async () => {
{ {
relays, maxEventContentLength, apps relays, maxEventContentLength, apps
}, },
(e, p) => mainHandler.liquidityProvider.onEvent(e, p) (e, p) => mainHandler.liquidityProvider.onEvent(e, p),
{ extensionLoader: extensionLoader || undefined }
) )
exitHandler(() => { Stop(); mainHandler.Stop() }) exitHandler(() => { Stop(); mainHandler.Stop() })
log("starting server") log("starting server")
@ -58,8 +105,58 @@ const start = async () => {
wizard.AddConnectInfo(appNprofile, relays) wizard.AddConnectInfo(appNprofile, relays)
} }
adminManager.setAppNprofile(appNprofile) adminManager.setAppNprofile(appNprofile)
// Create Express app for extension HTTP routes
const extensionApp = express()
extensionApp.use(cors()) // Enable CORS for all origins (ATM apps, wallets, etc.)
extensionApp.use(express.json())
// Mount extension HTTP routes
if (extensionLoader) {
for (const ext of extensionLoader.getAllExtensions()) {
if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) {
const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[]
for (const route of routes) {
log(`mounting extension route: ${route.method} ${route.path}`)
const handler = async (req: express.Request, res: express.Response) => {
try {
const httpReq = {
params: req.params,
query: req.query as Record<string, string>,
body: req.body,
headers: req.headers as Record<string, string>
}
const result = await route.handler(httpReq)
res.status(result.status)
if (result.headers) {
for (const [key, value] of Object.entries(result.headers)) {
res.setHeader(key, value)
}
}
res.json(result.body)
} catch (e: any) {
log(`extension route error: ${e.message}`)
res.status(500).json({ status: 'ERROR', reason: e.message })
}
}
if (route.method === 'GET') {
extensionApp.get(route.path, handler)
} else if (route.method === 'POST') {
extensionApp.post(route.path, handler)
}
}
}
}
}
// Start extension routes server
extensionApp.listen(extensionPort, () => {
log(`extension HTTP routes listening on port ${extensionPort}`)
})
// Start main proto server
const Server = NewServer(serverMethods, serverOptions(mainHandler)) const Server = NewServer(serverMethods, serverOptions(mainHandler))
Server.Listen(settingsManager.getSettings().serviceSettings.servicePort) Server.Listen(mainPort)
} }
start() start()

View file

@ -5,9 +5,15 @@ import * as Types from '../proto/autogenerated/ts/types.js'
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
import { ERROR, getLogger } from "./services/helpers/logger.js"; import { ERROR, getLogger } from "./services/helpers/logger.js";
import { NdebitData, NofferData, NmanageRequest } from "@shocknet/clink-sdk"; import { NdebitData, NofferData, NmanageRequest } from "@shocknet/clink-sdk";
import type { ExtensionLoader } from "./extensions/loader.js"
type ExportedCalls = { Stop: () => void, Send: NostrSend, Ping: () => Promise<void>, Reset: (settings: NostrSettings) => void } type ExportedCalls = { Stop: () => void, Send: NostrSend, Ping: () => Promise<void>, Reset: (settings: NostrSettings) => void }
type ClientEventCallback = (e: { requestId: string }, fromPub: string) => void type ClientEventCallback = (e: { requestId: string }, fromPub: string) => void
export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback): ExportedCalls => {
export type NostrMiddlewareOptions = {
extensionLoader?: ExtensionLoader
}
export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: ClientEventCallback, options?: NostrMiddlewareOptions): ExportedCalls => {
const log = getLogger({}) const log = getLogger({})
const nostrTransport = NewNostrTransport(serverMethods, { const nostrTransport = NewNostrTransport(serverMethods, {
NostrUserAuthGuard: async (appId, pub) => { NostrUserAuthGuard: async (appId, pub) => {
@ -95,6 +101,31 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub) log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub)
return return
} }
// Check if this is an extension RPC method
const extensionLoader = options?.extensionLoader
if (extensionLoader && j.rpcName && extensionLoader.hasMethod(j.rpcName)) {
// Route to extension
log(`[Nostr] Routing to extension method: ${j.rpcName}`)
extensionLoader.callMethod(j.rpcName, j.body || {}, event.appId, event.pub)
.then(result => {
const response = { status: 'OK', requestId: j.requestId, ...result }
nostr.Send(
{ type: 'app', appId: event.appId },
{ type: 'content', pub: event.pub, content: JSON.stringify(response) }
)
})
.catch(err => {
log(ERROR, `Extension method ${j.rpcName} failed:`, err.message)
const response = { status: 'ERROR', requestId: j.requestId, reason: err.message }
nostr.Send(
{ type: 'app', appId: event.appId },
{ type: 'content', pub: event.pub, content: JSON.stringify(response) }
)
})
return
}
nostrTransport({ ...j, appId: event.appId }, res => { nostrTransport({ ...j, appId: event.appId }, res => {
nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) }) nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) })
}, event.startAtNano, event.startAtMs) }, event.startAtNano, event.startAtMs)
@ -105,7 +136,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
return { return {
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop }, Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
Send: (...args) => nostr.Send(...args), Send: async (...args) => nostr.Send(...args),
Ping: () => nostr.Ping(), Ping: () => nostr.Ping(),
Reset: (settings: NostrSettings) => nostr.Reset(settings) Reset: (settings: NostrSettings) => nostr.Reset(settings)
} }

View file

@ -1,17 +1,17 @@
import fs from 'fs' import fs from 'fs'
export const DEBUG = Symbol("DEBUG") export const DEBUG = Symbol("DEBUG")
export const ERROR = Symbol("ERROR") export const ERROR = Symbol("ERROR")
export const WARN = Symbol("WARN") export const INFO = Symbol("INFO")
type LoggerParams = { appName?: string, userId?: string, component?: string } type LoggerParams = { appName?: string, userId?: string, component?: string }
export type PubLogger = (...message: (string | number | object | symbol)[]) => void export type PubLogger = (...message: (string | number | object | symbol)[]) => void
type Writer = (message: string) => void type Writer = (message: string) => void
const logsDir = process.env.LOGS_DIR || "logs" const logsDir = process.env.LOGS_DIR || "logs"
const logLevel = process.env.LOG_LEVEL || "DEBUG" const logLevel = process.env.LOG_LEVEL || "INFO"
try { try {
fs.mkdirSync(logsDir) fs.mkdirSync(logsDir)
} catch { } } catch { }
if (logLevel !== "DEBUG" && logLevel !== "WARN" && logLevel !== "ERROR") { if (logLevel !== "DEBUG" && logLevel !== "INFO" && logLevel !== "ERROR") {
throw new Error("Invalid log level " + logLevel + " must be one of (DEBUG, WARN, ERROR)") throw new Error("Invalid log level " + logLevel + " must be one of (DEBUG, INFO, ERROR)")
} }
const z = (n: number) => n < 10 ? `0${n}` : `${n}` const z = (n: number) => n < 10 ? `0${n}` : `${n}`
// Sanitize filename to remove invalid characters for filesystem // Sanitize filename to remove invalid characters for filesystem
@ -67,19 +67,17 @@ export const getLogger = (params: LoggerParams): PubLogger => {
} }
message[0] = "DEBUG" message[0] = "DEBUG"
break; break;
case WARN: case INFO:
if (logLevel === "ERROR") { if (logLevel === "ERROR") {
return return
} }
message[0] = "WARN" message[0] = "INFO"
break; break;
case ERROR: case ERROR:
message[0] = "ERROR" message[0] = "ERROR"
break; break;
default: default:
if (logLevel !== "DEBUG") { // treats logs without a level as ERROR level, without prefix so it can be found and fixed if needed
return
}
} }
const now = new Date() const now = new Date()
const timestamp = `${now.getFullYear()}-${z(now.getMonth() + 1)}-${z(now.getDate())} ${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}` const timestamp = `${now.getFullYear()}-${z(now.getMonth() + 1)}-${z(now.getDate())} ${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}`

View file

@ -15,7 +15,7 @@ import { AddInvoiceReq } from './addInvoiceReq.js';
import { PayInvoiceReq } from './payInvoiceReq.js'; import { PayInvoiceReq } from './payInvoiceReq.js';
import { SendCoinsReq } from './sendCoinsReq.js'; import { SendCoinsReq } from './sendCoinsReq.js';
import { AddressPaidCb, InvoicePaidCb, NodeInfo, Invoice, DecodedInvoice, PaidInvoice, NewBlockCb, HtlcCb, BalanceInfo, ChannelEventCb } from './settings.js'; import { AddressPaidCb, InvoicePaidCb, NodeInfo, Invoice, DecodedInvoice, PaidInvoice, NewBlockCb, HtlcCb, BalanceInfo, ChannelEventCb } from './settings.js';
import { ERROR, getLogger } from '../helpers/logger.js'; import { ERROR, getLogger, DEBUG, INFO } from '../helpers/logger.js';
import { HtlcEvent_EventType } from '../../../proto/lnd/router.js'; import { HtlcEvent_EventType } from '../../../proto/lnd/router.js';
import { LiquidityProvider } from '../main/liquidityProvider.js'; import { LiquidityProvider } from '../main/liquidityProvider.js';
import { Utils } from '../helpers/utilsWrapper.js'; import { Utils } from '../helpers/utilsWrapper.js';
@ -23,7 +23,7 @@ import { TxPointSettings } from '../storage/tlv/stateBundler.js';
import { WalletKitClient } from '../../../proto/lnd/walletkit.client.js'; import { WalletKitClient } from '../../../proto/lnd/walletkit.client.js';
import SettingsManager from '../main/settingsManager.js'; import SettingsManager from '../main/settingsManager.js';
import { LndNodeSettings, LndSettings } from '../main/settings.js'; import { LndNodeSettings, LndSettings } from '../main/settings.js';
import { ListAddressesResponse } from '../../../proto/lnd/walletkit.js'; import { ListAddressesResponse, PublishResponse } from '../../../proto/lnd/walletkit.js';
const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline }) const DeadLineMetadata = (deadline = 10 * 1000) => ({ deadline: Date.now() + deadline })
const deadLndRetrySeconds = 20 const deadLndRetrySeconds = 20
@ -69,7 +69,7 @@ export default class {
// Skip LND client initialization if using only liquidity provider // Skip LND client initialization if using only liquidity provider
if (liquidProvider.getSettings().useOnlyLiquidityProvider) { if (liquidProvider.getSettings().useOnlyLiquidityProvider) {
this.log("USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND client initialization") this.log(INFO, "USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND client initialization")
// Create minimal dummy clients - they won't be used but prevent null reference errors // Create minimal dummy clients - they won't be used but prevent null reference errors
// Use insecure credentials directly (can't combine them) // Use insecure credentials directly (can't combine them)
const { lndAddr } = this.getSettings().lndNodeSettings const { lndAddr } = this.getSettings().lndNodeSettings
@ -126,13 +126,13 @@ export default class {
} }
async Warmup() { async Warmup() {
this.log(INFO, "Warming up LND")
// Skip LND warmup if using only liquidity provider // Skip LND warmup if using only liquidity provider
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
this.log("USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND warmup") this.log(INFO, "USE_ONLY_LIQUIDITY_PROVIDER enabled, skipping LND warmup")
this.ready = true this.ready = true
return return
} }
// console.log("Warming up LND")
this.SubscribeAddressPaid() this.SubscribeAddressPaid()
this.SubscribeInvoicePaid() this.SubscribeInvoicePaid()
await this.SubscribeNewBlock() await this.SubscribeNewBlock()
@ -142,20 +142,32 @@ export default class {
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
await this.GetInfo() const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
this.log("LND responding but not synced yet, waiting...")
return
}
clearInterval(interval) clearInterval(interval)
this.ready = true this.ready = true
res() res()
} catch (err) { } catch (err) {
this.log("LND is not ready yet, will try again in 1 second") this.log(INFO, "LND is not ready yet, will try again in 1 second")
if (Date.now() - now > 1000 * 60) { }
rej(new Error("LND not ready after 1 minute")) if (Date.now() - now > 1000 * 60 * 10) {
} clearInterval(interval)
rej(new Error("LND not synced after 10 minutes"))
} }
}, 1000) }, 1000)
}) })
} }
async PublishTransaction(txHex: string): Promise<PublishResponse> {
const res = await this.walletKit.publishTransaction({
txHex: Buffer.from(txHex, 'hex'), label: ""
}, DeadLineMetadata())
return res.response
}
async GetInfo(): Promise<NodeInfo> { async GetInfo(): Promise<NodeInfo> {
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
// Return dummy info when bypass is enabled // Return dummy info when bypass is enabled
@ -169,27 +181,26 @@ export default class {
uris: [] uris: []
} }
} }
// console.log("Getting info")
const res = await this.lightning.getInfo({}, DeadLineMetadata()) const res = await this.lightning.getInfo({}, DeadLineMetadata())
return res.response return res.response
} }
async ListPendingChannels(): Promise<PendingChannelsResponse> { async ListPendingChannels(): Promise<PendingChannelsResponse> {
this.log(DEBUG, "Listing pending channels")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { pendingOpenChannels: [], pendingClosingChannels: [], pendingForceClosingChannels: [], waitingCloseChannels: [], totalLimboBalance: 0n } return { pendingOpenChannels: [], pendingClosingChannels: [], pendingForceClosingChannels: [], waitingCloseChannels: [], totalLimboBalance: 0n }
} }
// console.log("Listing pending channels")
const res = await this.lightning.pendingChannels({ includeRawTx: false }, DeadLineMetadata()) const res = await this.lightning.pendingChannels({ includeRawTx: false }, DeadLineMetadata())
return res.response return res.response
} }
async ListChannels(peerLookup = false): Promise<ListChannelsResponse> { async ListChannels(peerLookup = false): Promise<ListChannelsResponse> {
// console.log("Listing channels") this.log(DEBUG, "Listing channels")
const res = await this.lightning.listChannels({ const res = await this.lightning.listChannels({
activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0), peerAliasLookup: peerLookup activeOnly: false, inactiveOnly: false, privateOnly: false, publicOnly: false, peer: Buffer.alloc(0), peerAliasLookup: peerLookup
}, DeadLineMetadata()) }, DeadLineMetadata())
return res.response return res.response
} }
async ListClosedChannels(): Promise<ClosedChannelsResponse> { async ListClosedChannels(): Promise<ClosedChannelsResponse> {
// console.log("Listing closed channels") this.log(DEBUG, "Listing closed channels")
const res = await this.lightning.closedChannels({ const res = await this.lightning.closedChannels({
abandoned: true, abandoned: true,
breach: true, breach: true,
@ -206,7 +217,6 @@ export default class {
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return return
} }
// console.log("Checking health")
if (!this.ready) { if (!this.ready) {
throw new Error("not ready") throw new Error("not ready")
} }
@ -217,69 +227,69 @@ export default class {
} }
RestartStreams() { RestartStreams() {
// console.log("Restarting streams") this.log(INFO, "Restarting streams")
if (!this.ready || this.abortController.signal.aborted) { if (!this.ready || this.abortController.signal.aborted) {
return return
} }
this.log("LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds") this.log(INFO, "LND is dead, will try to reconnect in", deadLndRetrySeconds, "seconds")
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
await this.unlockLnd() await this.unlockLnd()
this.log("LND is back online") this.log(INFO, "LND is back online")
clearInterval(interval) clearInterval(interval)
await this.Warmup() await this.Warmup()
} catch (err) { } catch (err) {
this.log("LND still dead, will try again in", deadLndRetrySeconds, "seconds") this.log(INFO, "LND still dead, will try again in", deadLndRetrySeconds, "seconds")
} }
}, deadLndRetrySeconds * 1000) }, deadLndRetrySeconds * 1000)
} }
async SubscribeChannelEvents() { async SubscribeChannelEvents() {
// console.log("Subscribing to channel events") this.log(DEBUG, "Subscribing to channel events")
const stream = this.lightning.subscribeChannelEvents({}, { abort: this.abortController.signal }) const stream = this.lightning.subscribeChannelEvents({}, { abort: this.abortController.signal })
stream.responses.onMessage(async channel => { stream.responses.onMessage(async channel => {
const channels = await this.ListChannels() const channels = await this.ListChannels()
this.channelEventCb(channel, channels.channels) this.channelEventCb(channel, channels.channels)
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("Error with subscribeChannelEvents stream") this.log(ERROR, "Error with subscribeChannelEvents stream")
}) })
stream.responses.onComplete(() => { stream.responses.onComplete(() => {
this.log("subscribeChannelEvents stream closed") this.log(INFO, "subscribeChannelEvents stream closed")
}) })
} }
async SubscribeHtlcEvents() { async SubscribeHtlcEvents() {
// console.log("Subscribing to htlc events") this.log(DEBUG, "Subscribing to htlc events")
const stream = this.router.subscribeHtlcEvents({}, { abort: this.abortController.signal }) const stream = this.router.subscribeHtlcEvents({}, { abort: this.abortController.signal })
stream.responses.onMessage(htlc => { stream.responses.onMessage(htlc => {
this.htlcCb(htlc) this.htlcCb(htlc)
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("Error with subscribeHtlcEvents stream") this.log(ERROR, "Error with subscribeHtlcEvents stream")
}) })
stream.responses.onComplete(() => { stream.responses.onComplete(() => {
this.log("subscribeHtlcEvents stream closed") this.log(INFO, "subscribeHtlcEvents stream closed")
}) })
} }
async SubscribeNewBlock() { async SubscribeNewBlock() {
// console.log("Subscribing to new block") this.log(DEBUG, "Subscribing to new block")
const { blockHeight } = await this.GetInfo() const { blockHeight } = await this.GetInfo()
const stream = this.chainNotifier.registerBlockEpochNtfn({ height: blockHeight, hash: Buffer.alloc(0) }, { abort: this.abortController.signal }) const stream = this.chainNotifier.registerBlockEpochNtfn({ height: blockHeight, hash: Buffer.alloc(0) }, { abort: this.abortController.signal })
stream.responses.onMessage(block => { stream.responses.onMessage(block => {
this.newBlockCb(block.height) this.newBlockCb(block.height)
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("Error with new block stream") this.log(ERROR, "Error with new block stream")
}) })
stream.responses.onComplete(() => { stream.responses.onComplete(() => {
this.log("new block stream closed") this.log(INFO, "new block stream closed")
}) })
} }
SubscribeAddressPaid(): void { SubscribeAddressPaid(): void {
// console.log("Subscribing to address paid") this.log(DEBUG, "Subscribing to address paid")
const stream = this.lightning.subscribeTransactions({ const stream = this.lightning.subscribeTransactions({
account: "", account: "",
endHeight: 0, endHeight: 0,
@ -298,15 +308,15 @@ export default class {
} }
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("Error with onchain tx stream") this.log(ERROR, "Error with onchain tx stream")
}) })
stream.responses.onComplete(() => { stream.responses.onComplete(() => {
this.log("onchain tx stream closed") this.log(INFO, "onchain tx stream closed")
}) })
} }
SubscribeInvoicePaid(): void { SubscribeInvoicePaid(): void {
// console.log("Subscribing to invoice paid") this.log(DEBUG, "Subscribing to invoice paid")
const stream = this.lightning.subscribeInvoices({ const stream = this.lightning.subscribeInvoices({
settleIndex: BigInt(this.latestKnownSettleIndex), settleIndex: BigInt(this.latestKnownSettleIndex),
addIndex: 0n, addIndex: 0n,
@ -319,14 +329,14 @@ export default class {
}) })
let restarted = false let restarted = false
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("Error with invoice stream") this.log(ERROR, "Error with invoice stream")
if (!restarted) { if (!restarted) {
restarted = true restarted = true
this.RestartStreams() this.RestartStreams()
} }
}) })
stream.responses.onComplete(() => { stream.responses.onComplete(() => {
this.log("invoice stream closed") this.log(INFO, "invoice stream closed")
if (!restarted) { if (!restarted) {
restarted = true restarted = true
this.RestartStreams() this.RestartStreams()
@ -348,6 +358,7 @@ export default class {
} }
async ListAddresses(): Promise<LndAddress[]> { async ListAddresses(): Promise<LndAddress[]> {
this.log(DEBUG, "Listing addresses")
const res = await this.walletKit.listAddresses({ accountName: "", showCustomAccounts: false }, DeadLineMetadata()) const res = await this.walletKit.listAddresses({ accountName: "", showCustomAccounts: false }, DeadLineMetadata())
const addresses = res.response.accountWithAddresses.map(a => a.addresses.map(a => ({ address: a.address, change: a.isInternal }))).flat() const addresses = res.response.accountWithAddresses.map(a => a.addresses.map(a => ({ address: a.address, change: a.isInternal }))).flat()
addresses.forEach(a => this.addressesCache[a.address] = { isChange: a.change }) addresses.forEach(a => this.addressesCache[a.address] = { isChange: a.change })
@ -355,11 +366,11 @@ export default class {
} }
async NewAddress(addressType: Types.AddressType, { useProvider, from }: TxActionOptions): Promise<NewAddressResponse> { async NewAddress(addressType: Types.AddressType, { useProvider, from }: TxActionOptions): Promise<NewAddressResponse> {
this.log(DEBUG, "Creating new address")
// Force use of provider when bypass is enabled (addresses not supported by provider, but we should fail gracefully) // Force use of provider when bypass is enabled (addresses not supported by provider, but we should fail gracefully)
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
throw new Error("Address generation not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") throw new Error("Address generation not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled")
} }
// console.log("Creating new address")
let lndAddressType: AddressType let lndAddressType: AddressType
switch (addressType) { switch (addressType) {
case Types.AddressType.NESTED_PUBKEY_HASH: case Types.AddressType.NESTED_PUBKEY_HASH:
@ -389,11 +400,11 @@ export default class {
} }
async NewInvoice(value: number, memo: string, expiry: number, { useProvider, from }: TxActionOptions, blind = false): Promise<Invoice> { async NewInvoice(value: number, memo: string, expiry: number, { useProvider, from }: TxActionOptions, blind = false): Promise<Invoice> {
// console.log("Creating new invoice") this.log(DEBUG, "Creating new invoice")
// Force use of provider when bypass is enabled // Force use of provider when bypass is enabled
const mustUseProvider = this.liquidProvider.getSettings().useOnlyLiquidityProvider || useProvider const mustUseProvider = this.liquidProvider.getSettings().useOnlyLiquidityProvider || useProvider
if (mustUseProvider) { if (mustUseProvider) {
console.log("using provider") this.log(INFO, "using provider")
const invoice = await this.liquidProvider.AddInvoice(value, memo, from, expiry) const invoice = await this.liquidProvider.AddInvoice(value, memo, from, expiry)
const providerPubkey = this.liquidProvider.GetProviderPubkey() const providerPubkey = this.liquidProvider.GetProviderPubkey()
return { payRequest: invoice, providerPubkey } return { payRequest: invoice, providerPubkey }
@ -409,6 +420,7 @@ export default class {
} }
async DecodeInvoice(paymentRequest: string): Promise<DecodedInvoice> { async DecodeInvoice(paymentRequest: string): Promise<DecodedInvoice> {
this.log(DEBUG, "Decoding invoice")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
// Use light-bolt11-decoder when LND is bypassed // Use light-bolt11-decoder when LND is bypassed
try { try {
@ -434,24 +446,23 @@ export default class {
throw new Error(`Failed to decode invoice: ${err.message}`) throw new Error(`Failed to decode invoice: ${err.message}`)
} }
} }
// console.log("Decoding invoice")
const res = await this.lightning.decodePayReq({ payReq: paymentRequest }, DeadLineMetadata()) const res = await this.lightning.decodePayReq({ payReq: paymentRequest }, DeadLineMetadata())
return { numSatoshis: Number(res.response.numSatoshis), paymentHash: res.response.paymentHash } return { numSatoshis: Number(res.response.numSatoshis), paymentHash: res.response.paymentHash }
} }
async ChannelBalance(): Promise<{ local: number, remote: number }> { async ChannelBalance(): Promise<{ local: number, remote: number }> {
this.log(DEBUG, "Getting channel balance")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { local: 0, remote: 0 } return { local: 0, remote: 0 }
} }
// console.log("Getting channel balance")
const res = await this.lightning.channelBalance({}) const res = await this.lightning.channelBalance({})
const r = res.response const r = res.response
return { local: r.localBalance ? Number(r.localBalance.sat) : 0, remote: r.remoteBalance ? Number(r.remoteBalance.sat) : 0 } return { local: r.localBalance ? Number(r.localBalance.sat) : 0, remote: r.remoteBalance ? Number(r.remoteBalance.sat) : 0 }
} }
async PayInvoice(invoice: string, amount: number, { routingFeeLimit, serviceFee }: { routingFeeLimit: number, serviceFee: number }, decodedAmount: number, { useProvider, from }: TxActionOptions, paymentIndexCb?: (index: number) => void): Promise<PaidInvoice> { async PayInvoice(invoice: string, amount: number, { routingFeeLimit, serviceFee }: { routingFeeLimit: number, serviceFee: number }, decodedAmount: number, { useProvider, from }: TxActionOptions, paymentIndexCb?: (index: number) => void): Promise<PaidInvoice> {
// console.log("Paying invoice") this.log(DEBUG, "Paying invoice")
if (this.outgoingOpsLocked) { if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request") this.log(ERROR, "outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync") throw new Error("lnd node is currently out of sync")
} }
// Force use of provider when bypass is enabled // Force use of provider when bypass is enabled
@ -468,7 +479,7 @@ export default class {
const stream = this.router.sendPaymentV2(req, { abort: abortController.signal }) const stream = this.router.sendPaymentV2(req, { abort: abortController.signal })
return new Promise((res, rej) => { return new Promise((res, rej) => {
stream.responses.onError(error => { stream.responses.onError(error => {
this.log("invoice payment failed", error) this.log(ERROR, "invoice payment failed", error)
rej(error) rej(error)
}) })
let indexSent = false let indexSent = false
@ -480,7 +491,7 @@ export default class {
} }
switch (payment.status) { switch (payment.status) {
case Payment_PaymentStatus.FAILED: case Payment_PaymentStatus.FAILED:
this.log("invoice payment failed", payment.failureReason) this.log(ERROR, "invoice payment failed", payment.failureReason)
rej(PaymentFailureReason[payment.failureReason]) rej(PaymentFailureReason[payment.failureReason])
return return
case Payment_PaymentStatus.SUCCEEDED: case Payment_PaymentStatus.SUCCEEDED:
@ -498,7 +509,7 @@ export default class {
} }
async EstimateChainFees(address: string, amount: number, targetConf: number): Promise<EstimateFeeResponse> { async EstimateChainFees(address: string, amount: number, targetConf: number): Promise<EstimateFeeResponse> {
// console.log("Estimating chain fees") this.log(DEBUG, "Estimating chain fees")
await this.Health() await this.Health()
const res = await this.lightning.estimateFee({ const res = await this.lightning.estimateFee({
addrToAmount: { [address]: BigInt(amount) }, addrToAmount: { [address]: BigInt(amount) },
@ -511,13 +522,13 @@ export default class {
} }
async PayAddress(address: string, amount: number, satPerVByte: number, label = "", { useProvider, from }: TxActionOptions): Promise<SendCoinsResponse> { async PayAddress(address: string, amount: number, satPerVByte: number, label = "", { useProvider, from }: TxActionOptions): Promise<SendCoinsResponse> {
this.log(DEBUG, "Paying address")
// Address payments not supported when bypass is enabled // Address payments not supported when bypass is enabled
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
throw new Error("Address payments not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled") throw new Error("Address payments not supported when USE_ONLY_LIQUIDITY_PROVIDER is enabled")
} }
// console.log("Paying address")
if (this.outgoingOpsLocked) { if (this.outgoingOpsLocked) {
this.log("outgoing ops locked, rejecting payment request") this.log(ERROR, "outgoing ops locked, rejecting payment request")
throw new Error("lnd node is currently out of sync") throw new Error("lnd node is currently out of sync")
} }
if (useProvider) { if (useProvider) {
@ -535,19 +546,19 @@ export default class {
} }
async GetTransactions(startHeight: number): Promise<TransactionDetails> { async GetTransactions(startHeight: number): Promise<TransactionDetails> {
// console.log("Getting transactions") this.log(DEBUG, "Getting transactions")
const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "" }, DeadLineMetadata()) const res = await this.lightning.getTransactions({ startHeight, endHeight: 0, account: "", }, DeadLineMetadata())
return res.response return res.response
} }
async GetChannelInfo(chanId: string) { async GetChannelInfo(chanId: string) {
// console.log("Getting channel info") this.log(DEBUG, "Getting channel info")
const res = await this.lightning.getChanInfo({ chanId, chanPoint: "" }, DeadLineMetadata()) const res = await this.lightning.getChanInfo({ chanId, chanPoint: "" }, DeadLineMetadata())
return res.response return res.response
} }
async UpdateChannelPolicy(chanPoint: string, policy: Types.ChannelPolicy) { async UpdateChannelPolicy(chanPoint: string, policy: Types.ChannelPolicy) {
// console.log("Updating channel policy") this.log(DEBUG, "Updating channel policy")
const split = chanPoint.split(':') const split = chanPoint.split(':')
const res = await this.lightning.updateChannelPolicy({ const res = await this.lightning.updateChannelPolicy({
@ -565,19 +576,19 @@ export default class {
} }
async GetChannelBalance() { async GetChannelBalance() {
// console.log("Getting channel balance") this.log(DEBUG, "Getting channel balance")
const res = await this.lightning.channelBalance({}, DeadLineMetadata()) const res = await this.lightning.channelBalance({}, DeadLineMetadata())
return res.response return res.response
} }
async GetWalletBalance() { async GetWalletBalance() {
// console.log("Getting wallet balance") this.log(DEBUG, "Getting wallet balance")
const res = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata()) const res = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata())
return res.response return res.response
} }
async GetTotalBalace() { async GetTotalBalace() {
// console.log("Getting total balance") this.log(DEBUG, "Getting total balance")
const walletBalance = await this.GetWalletBalance() const walletBalance = await this.GetWalletBalance()
const confirmedWalletBalance = Number(walletBalance.confirmedBalance) const confirmedWalletBalance = Number(walletBalance.confirmedBalance)
this.utils.stateBundler.AddBalancePoint('walletBalance', confirmedWalletBalance) this.utils.stateBundler.AddBalancePoint('walletBalance', confirmedWalletBalance)
@ -592,10 +603,10 @@ export default class {
} }
async GetBalance(): Promise<BalanceInfo> { // TODO: remove this async GetBalance(): Promise<BalanceInfo> { // TODO: remove this
this.log(DEBUG, "Getting balance")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { confirmedBalance: 0, unconfirmedBalance: 0, totalBalance: 0, channelsBalance: [] } return { confirmedBalance: 0, unconfirmedBalance: 0, totalBalance: 0, channelsBalance: [] }
} }
// console.log("Getting balance")
const wRes = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata()) const wRes = await this.lightning.walletBalance({ account: "", minConfs: 1 }, DeadLineMetadata())
const { confirmedBalance, unconfirmedBalance, totalBalance } = wRes.response const { confirmedBalance, unconfirmedBalance, totalBalance } = wRes.response
const { response } = await this.lightning.listChannels({ const { response } = await this.lightning.listChannels({
@ -611,33 +622,47 @@ export default class {
} }
async GetForwardingHistory(indexOffset: number, startTime = 0, endTime = 0): Promise<ForwardingHistoryResponse> { async GetForwardingHistory(indexOffset: number, startTime = 0, endTime = 0): Promise<ForwardingHistoryResponse> {
this.log(DEBUG, "Getting forwarding history")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { forwardingEvents: [], lastOffsetIndex: indexOffset } return { forwardingEvents: [], lastOffsetIndex: indexOffset }
} }
// console.log("Getting forwarding history")
const { response } = await this.lightning.forwardingHistory({ indexOffset, numMaxEvents: 0, startTime: BigInt(startTime), endTime: BigInt(endTime), peerAliasLookup: false }, DeadLineMetadata()) const { response } = await this.lightning.forwardingHistory({ indexOffset, numMaxEvents: 0, startTime: BigInt(startTime), endTime: BigInt(endTime), peerAliasLookup: false }, DeadLineMetadata())
return response return response
} }
async GetAllPaidInvoices(max: number) { async GetAllInvoices(max: number) {
this.log(DEBUG, "Getting all paid invoices")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { invoices: [] } return { invoices: [] }
} }
// console.log("Getting all paid invoices")
const res = await this.lightning.listInvoices({ indexOffset: 0n, numMaxInvoices: BigInt(max), pendingOnly: false, reversed: true, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata()) const res = await this.lightning.listInvoices({ indexOffset: 0n, numMaxInvoices: BigInt(max), pendingOnly: false, reversed: true, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata())
return res.response return res.response
} }
async GetAllPayments(max: number) { async GetAllPayments(max: number) {
this.log(DEBUG, "Getting all payments")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return { payments: [] } return { payments: [] }
} }
// console.log("Getting all payments")
const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset: 0n, maxPayments: BigInt(max), reversed: true, creationDateEnd: 0n, creationDateStart: 0n }) const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset: 0n, maxPayments: BigInt(max), reversed: true, creationDateEnd: 0n, creationDateStart: 0n })
return res.response return res.response
} }
async BumpFee(txId: string, outputIndex: number, satPerVbyte: number) {
this.log(DEBUG, "Bumping fee")
const res = await this.walletKit.bumpFee({
budget: 0n, immediate: false, targetConf: 0, satPerVbyte: BigInt(satPerVbyte), outpoint: {
txidStr: txId,
outputIndex: outputIndex,
txidBytes: Buffer.alloc(0)
},
force: false,
satPerByte: 0
}, DeadLineMetadata())
return res.response
}
async GetPayment(paymentIndex: number) { async GetPayment(paymentIndex: number) {
// console.log("Getting payment") this.log(DEBUG, "Getting payment")
if (paymentIndex === 0) { if (paymentIndex === 0) {
throw new Error("payment index starts from 1") throw new Error("payment index starts from 1")
} }
@ -649,10 +674,10 @@ export default class {
} }
async GetLatestPaymentIndex(from = 0) { async GetLatestPaymentIndex(from = 0) {
this.log(DEBUG, "Getting latest payment index")
if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidProvider.getSettings().useOnlyLiquidityProvider) {
return from return from
} }
// console.log("Getting latest payment index")
let indexOffset = BigInt(from) let indexOffset = BigInt(from)
while (true) { while (true) {
const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset, maxPayments: 0n, reversed: false, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata()) const res = await this.lightning.listPayments({ countTotalPayments: false, includeIncomplete: false, indexOffset, maxPayments: 0n, reversed: false, creationDateEnd: 0n, creationDateStart: 0n }, DeadLineMetadata())
@ -664,7 +689,7 @@ export default class {
} }
async ConnectPeer(addr: { pubkey: string, host: string }) { async ConnectPeer(addr: { pubkey: string, host: string }) {
// console.log("Connecting to peer") this.log(DEBUG, "Connecting to peer")
const res = await this.lightning.connectPeer({ const res = await this.lightning.connectPeer({
addr, addr,
perm: true, perm: true,
@ -674,7 +699,7 @@ export default class {
} }
async GetPaymentFromHash(paymentHash: string): Promise<Payment | null> { async GetPaymentFromHash(paymentHash: string): Promise<Payment | null> {
// console.log("Getting payment from hash") this.log(DEBUG, "Getting payment from hash")
const abortController = new AbortController() const abortController = new AbortController()
const stream = this.router.trackPaymentV2({ const stream = this.router.trackPaymentV2({
paymentHash: Buffer.from(paymentHash, 'hex'), paymentHash: Buffer.from(paymentHash, 'hex'),
@ -696,13 +721,12 @@ export default class {
} }
async GetTx(txid: string) { async GetTx(txid: string) {
// console.log("Getting transaction")
const res = await this.walletKit.getTransaction({ txid }, DeadLineMetadata()) const res = await this.walletKit.getTransaction({ txid }, DeadLineMetadata())
return res.response return res.response
} }
async AddPeer(pub: string, host: string, port: number) { async AddPeer(pub: string, host: string, port: number) {
// console.log("Adding peer") this.log(DEBUG, "Adding peer")
const res = await this.lightning.connectPeer({ const res = await this.lightning.connectPeer({
addr: { addr: {
pubkey: pub, pubkey: pub,
@ -715,19 +739,19 @@ export default class {
} }
async ListPeers() { async ListPeers() {
// console.log("Listing peers") this.log(DEBUG, "Listing peers")
const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata()) const res = await this.lightning.listPeers({ latestError: true }, DeadLineMetadata())
return res.response return res.response
} }
async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number, satsPerVByte: number): Promise<OpenStatusUpdate> { async OpenChannel(destination: string, closeAddress: string, fundingAmount: number, pushSats: number, satsPerVByte: number): Promise<OpenStatusUpdate> {
// console.log("Opening channel") this.log(DEBUG, "Opening channel")
const abortController = new AbortController() const abortController = new AbortController()
const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats, satsPerVByte) const req = OpenChannelReq(destination, closeAddress, fundingAmount, pushSats, satsPerVByte)
const stream = this.lightning.openChannel(req, { abort: abortController.signal }) const stream = this.lightning.openChannel(req, { abort: abortController.signal })
return new Promise((res, rej) => { return new Promise((res, rej) => {
stream.responses.onMessage(message => { stream.responses.onMessage(message => {
console.log("message", message) this.log(DEBUG, "open channel message", message)
switch (message.update.oneofKind) { switch (message.update.oneofKind) {
case 'chanPending': case 'chanPending':
res(message) res(message)
@ -735,14 +759,14 @@ export default class {
} }
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
console.log("error", error) this.log(ERROR, "open channel error", error)
rej(error) rej(error)
}) })
}) })
} }
async CloseChannel(fundingTx: string, outputIndex: number, force: boolean, satPerVByte: number): Promise<PendingUpdate> { async CloseChannel(fundingTx: string, outputIndex: number, force: boolean, satPerVByte: number): Promise<PendingUpdate> {
// console.log("Closing channel") this.log(DEBUG, "Closing channel")
const stream = this.lightning.closeChannel({ const stream = this.lightning.closeChannel({
deliveryAddress: "", deliveryAddress: "",
force: force, force: force,
@ -761,7 +785,7 @@ export default class {
}, DeadLineMetadata()) }, DeadLineMetadata())
return new Promise((res, rej) => { return new Promise((res, rej) => {
stream.responses.onMessage(message => { stream.responses.onMessage(message => {
console.log("message", message) this.log(DEBUG, "close channel message", message)
switch (message.update.oneofKind) { switch (message.update.oneofKind) {
case 'closePending': case 'closePending':
res(message.update.closePending) res(message.update.closePending)
@ -769,7 +793,7 @@ export default class {
} }
}) })
stream.responses.onError(error => { stream.responses.onError(error => {
console.log("error", error) this.log(ERROR, "close channel error", error)
rej(error) rej(error)
}) })
}) })

View file

@ -9,7 +9,7 @@ export const PayInvoiceReq = (invoice: string, amount: number, feeLimit: number)
maxParts: 3, maxParts: 3,
timeoutSeconds: 50, timeoutSeconds: 50,
allowSelfPayment: false, allowSelfPayment: true,
amp: false, amp: false,
amtMsat: 0n, amtMsat: 0n,
cltvLimit: 0, cltvLimit: 0,

View file

@ -1,657 +0,0 @@
import zkpInit from '@vulpemventures/secp256k1-zkp';
import axios from 'axios';
import { crypto, initEccLib, Transaction, address, Network } from 'bitcoinjs-lib';
// import bolt11 from 'bolt11';
import {
Musig, SwapTreeSerializer, TaprootUtils, detectSwap,
constructClaimTransaction, targetFee, OutputType,
Networks,
} from 'boltz-core';
import { randomBytes, createHash } from 'crypto';
import { ECPairFactory, ECPairInterface } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import ws from 'ws';
import { getLogger, PubLogger, ERROR } from '../helpers/logger.js';
import SettingsManager from '../main/settingsManager.js';
import * as Types from '../../../proto/autogenerated/ts/types.js';
import { BTCNetwork } from '../main/settings.js';
import Storage from '../storage/index.js';
import LND from './lnd.js';
import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js';
type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string }
type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface }
type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo }
type TransactionSwapFees = {
percentage: number,
minerFees: {
claim: number,
lockup: number,
}
}
type TransactionSwapFeesRes = {
BTC?: {
BTC?: {
fees: TransactionSwapFees
}
}
}
type TransactionSwapResponse = {
id: string, refundPublicKey: string, swapTree: string,
timeoutBlockHeight: number, lockupAddress: string, invoice: string,
onchainAmount?: number
}
type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number }
export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo }
export class Swaps {
settings: SettingsManager
revSwappers: Record<string, ReverseSwaps>
// submarineSwaps: SubmarineSwaps
storage: Storage
lnd: LND
log = getLogger({ component: 'swaps' })
constructor(settings: SettingsManager, storage: Storage) {
this.settings = settings
this.revSwappers = {}
const network = settings.getSettings().lndSettings.network
const { boltzHttpUrl, boltzWebSocketUrl, boltsHttpUrlAlt, boltsWebSocketUrlAlt } = settings.getSettings().swapsSettings
if (boltzHttpUrl && boltzWebSocketUrl) {
this.revSwappers[boltzHttpUrl] = new ReverseSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network })
}
if (boltsHttpUrlAlt && boltsWebSocketUrlAlt) {
this.revSwappers[boltsHttpUrlAlt] = new ReverseSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network })
}
this.storage = storage
}
SetLnd = (lnd: LND) => {
this.lnd = lnd
}
Stop = () => { }
GetKeys = (privateKey: string) => {
const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex'))
return keys
}
ListSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise<Types.SwapsList> => {
const completedSwaps = await this.storage.paymentStorage.ListCompletedSwaps(appUserId, payments)
const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId)
return {
swaps: completedSwaps.map(s => {
const p = s.payment
const op = p ? newOp(p) : undefined
return {
operation_payment: op,
swap_operation_id: s.swap.swap_operation_id,
address_paid: s.swap.address_paid,
failure_reason: s.swap.failure_reason,
}
}),
quotes: pendingSwaps.map(s => {
const serviceFee = getServiceFee(s.invoice_amount)
return {
swap_operation_id: s.swap_operation_id,
invoice_amount_sats: s.invoice_amount,
transaction_amount_sats: s.transaction_amount,
chain_fee_sats: s.chain_fee_sats,
service_fee_sats: serviceFee,
swap_fee_sats: s.swap_fee_sats,
service_url: s.service_url,
}
})
}
}
GetTxSwapQuotes = async (appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise<Types.TransactionSwapQuote[]> => {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
const swappers = Object.values(this.revSwappers)
if (swappers.length === 0) {
throw new Error("No swap services available")
}
const res = await Promise.allSettled(swappers.map(sw => this.getTxSwapQuote(sw, appUserId, amt, getServiceFee)))
const failures: string[] = []
const success: Types.TransactionSwapQuote[] = []
for (const r of res) {
if (r.status === 'fulfilled') {
success.push(r.value)
} else {
failures.push(r.reason.message ? r.reason.message : r.reason.toString())
}
}
if (success.length === 0) {
throw new Error(failures.join("\n"))
}
return success
}
private async getTxSwapQuote(swapper: ReverseSwaps, appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise<Types.TransactionSwapQuote> {
this.log("getting transaction swap quote")
const feesRes = await swapper.GetFees()
if (!feesRes.ok) {
throw new Error(feesRes.error)
}
const { claim, lockup } = feesRes.fees.minerFees
const minerFee = claim + lockup
const chainTotal = amt + minerFee
const res = await swapper.SwapTransaction(chainTotal)
if (!res.ok) {
throw new Error(res.error)
}
const decoded = await this.lnd.DecodeInvoice(res.createdResponse.invoice)
const swapFee = decoded.numSatoshis - chainTotal
const serviceFee = getServiceFee(decoded.numSatoshis)
const newSwap = await this.storage.paymentStorage.AddTransactionSwap({
app_user_id: appUserId,
swap_quote_id: res.createdResponse.id,
swap_tree: JSON.stringify(res.createdResponse.swapTree),
lockup_address: res.createdResponse.lockupAddress,
refund_public_key: res.createdResponse.refundPublicKey,
timeout_block_height: res.createdResponse.timeoutBlockHeight,
invoice: res.createdResponse.invoice,
invoice_amount: decoded.numSatoshis,
transaction_amount: chainTotal,
swap_fee_sats: swapFee,
chain_fee_sats: minerFee,
preimage: res.preimage,
ephemeral_private_key: res.privKey,
ephemeral_public_key: res.pubkey,
service_url: swapper.getHttpUrl(),
})
return {
swap_operation_id: newSwap.swap_operation_id,
swap_fee_sats: swapFee,
invoice_amount_sats: decoded.numSatoshis,
transaction_amount_sats: amt,
chain_fee_sats: minerFee,
service_fee_sats: serviceFee,
service_url: swapper.getHttpUrl(),
}
}
async PayAddrWithSwap(appUserId: string, swapOpId: string, address: string, payInvoice: (invoice: string, amt: number) => Promise<void>) {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
this.log("paying address with swap", { appUserId, swapOpId, address })
if (!swapOpId) {
throw new Error("request a swap quote before paying an external address")
}
const txSwap = await this.storage.paymentStorage.GetTransactionSwap(swapOpId, appUserId)
if (!txSwap) {
throw new Error("swap quote not found")
}
const info = await this.lnd.GetInfo()
if (info.blockHeight >= txSwap.timeout_block_height) {
throw new Error("swap timeout")
}
const swapper = this.revSwappers[txSwap.service_url]
if (!swapper) {
throw new Error("swapper service not found")
}
const keys = this.GetKeys(txSwap.ephemeral_private_key)
const data: TransactionSwapData = {
createdResponse: {
id: txSwap.swap_quote_id,
invoice: txSwap.invoice,
lockupAddress: txSwap.lockup_address,
refundPublicKey: txSwap.refund_public_key,
swapTree: txSwap.swap_tree,
timeoutBlockHeight: txSwap.timeout_block_height,
onchainAmount: txSwap.transaction_amount,
},
info: {
destinationAddress: address,
keys,
chainFee: txSwap.chain_fee_sats,
preimage: Buffer.from(txSwap.preimage, 'hex'),
}
}
// the swap and the invoice payment are linked, swap will not start until the invoice payment is started, and will not complete once the invoice payment is completed
let swapResult = { ok: false, error: "swap never completed" } as { ok: true, txId: string } | { ok: false, error: string }
swapper.SubscribeToTransactionSwap(data, result => {
swapResult = result
})
try {
await payInvoice(txSwap.invoice, txSwap.invoice_amount)
if (!swapResult.ok) {
this.log("invoice payment successful, but swap failed")
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error)
throw new Error(swapResult.error)
}
this.log("swap completed successfully")
await this.storage.paymentStorage.FinalizeTransactionSwap(swapOpId, address, swapResult.txId)
} catch (err: any) {
if (swapResult.ok) {
this.log("failed to pay swap invoice, but swap completed successfully", swapResult.txId)
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, err.message)
} else {
this.log("failed to pay swap invoice and swap failed", swapResult.error)
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error)
}
throw err
}
const networkFeesTotal = txSwap.chain_fee_sats + txSwap.swap_fee_sats
return {
txId: swapResult.txId,
network_fee: networkFeesTotal
}
}
}
export class ReverseSwaps {
// settings: SettingsManager
private httpUrl: string
private wsUrl: string
log: PubLogger
private network: BTCNetwork
constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) {
this.httpUrl = httpUrl
this.wsUrl = wsUrl
this.network = network
this.log = getLogger({ component: 'ReverseSwaps' })
initEccLib(ecc)
}
getHttpUrl = () => {
return this.httpUrl
}
getWsUrl = () => {
return this.wsUrl
}
calculateFees = (fees: TransactionSwapFees, receiveAmount: number) => {
const pct = fees.percentage / 100
const minerFee = fees.minerFees.claim + fees.minerFees.lockup
const preFee = receiveAmount + minerFee
const fee = Math.ceil(preFee * pct)
const total = preFee + fee
return { total, fee, minerFee }
}
GetFees = async (): Promise<{ ok: true, fees: TransactionSwapFees, } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/swap/reverse`
const feesRes = await loggedGet<TransactionSwapFeesRes>(this.log, url)
if (!feesRes.ok) {
return { ok: false, error: feesRes.error }
}
if (!feesRes.data.BTC?.BTC?.fees) {
return { ok: false, error: 'No fees found for BTC to BTC swap' }
}
return { ok: true, fees: feesRes.data.BTC.BTC.fees }
}
SwapTransaction = async (txAmount: number): Promise<{ ok: true, createdResponse: TransactionSwapResponse, preimage: string, pubkey: string, privKey: string } | { ok: false, error: string }> => {
const preimage = randomBytes(32);
const keys = ECPairFactory(ecc).makeRandom()
if (!keys.privateKey) {
return { ok: false, error: 'Failed to generate keys' }
}
const url = `${this.httpUrl}/v2/swap/reverse`
const req: any = {
onchainAmount: txAmount,
to: 'BTC',
from: 'BTC',
claimPublicKey: Buffer.from(keys.publicKey).toString('hex'),
preimageHash: createHash('sha256').update(preimage).digest('hex'),
}
const createdResponseRes = await loggedPost<TransactionSwapResponse>(this.log, url, req)
if (!createdResponseRes.ok) {
return createdResponseRes
}
const createdResponse = createdResponseRes.data
this.log('Created transaction swap');
this.log(createdResponse);
return {
ok: true, createdResponse,
preimage: Buffer.from(preimage).toString('hex'),
pubkey: Buffer.from(keys.publicKey).toString('hex'),
privKey: Buffer.from(keys.privateKey).toString('hex')
}
}
SubscribeToTransactionSwap = async (data: TransactionSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => {
const webSocket = new ws(`${this.wsUrl}/v2/ws`)
const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] }
webSocket.on('open', () => {
webSocket.send(JSON.stringify(subReq))
})
let txId = "", isDone = false
const done = () => {
isDone = true
webSocket.close()
swapDone({ ok: true, txId })
}
webSocket.on('error', (err) => {
this.log(ERROR, 'Error in WebSocket', err.message)
})
webSocket.on('close', () => {
if (!isDone) {
this.log(ERROR, 'WebSocket closed before swap was done');
swapDone({ ok: false, error: 'WebSocket closed before swap was done' })
}
})
webSocket.on('message', async (rawMsg) => {
try {
const result = await this.handleSwapTransactionMessage(rawMsg, data, done)
if (result) {
txId = result
}
} catch (err: any) {
this.log(ERROR, 'Error handling transaction WebSocket message', err.message)
isDone = true
webSocket.close()
swapDone({ ok: false, error: err.message })
return
}
})
}
handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: () => void) => {
const msg = JSON.parse(rawMsg.toString('utf-8'));
if (msg.event !== 'update') {
return;
}
this.log('Got WebSocket update');
this.log(msg);
switch (msg.args[0].status) {
// "swap.created" means Boltz is waiting for the invoice to be paid
case 'swap.created':
this.log('Waiting invoice to be paid');
return;
// "transaction.mempool" means that Boltz sent an onchain transaction
case 'transaction.mempool':
const txIdRes = await this.handleTransactionMempool(data, msg.args[0].transaction.hex)
if (!txIdRes.ok) {
throw new Error(txIdRes.error)
}
return txIdRes.txId
case 'invoice.settled':
this.log('Transaction swap successful');
done()
return;
}
}
handleTransactionMempool = async (data: TransactionSwapData, txHex: string): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => {
this.log('Creating claim transaction');
const { createdResponse, info } = data
const { destinationAddress, keys, preimage, chainFee } = info
const boltzPublicKey = Buffer.from(
createdResponse.refundPublicKey,
'hex',
);
// Create a musig signing session and tweak it with the Taptree of the swap scripts
const musig = new Musig(await zkpInit(), keys, randomBytes(32), [
boltzPublicKey,
Buffer.from(keys.publicKey),
]);
const tweakedKey = TaprootUtils.tweakMusig(
musig,
// swap tree can either be a string or an object
SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree,
);
// Parse the lockup transaction and find the output relevant for the swap
const lockupTx = Transaction.fromHex(txHex);
const swapOutput = detectSwap(tweakedKey, lockupTx);
if (swapOutput === undefined) {
this.log(ERROR, 'No swap output found in lockup transaction');
return { ok: false, error: 'No swap output found in lockup transaction' }
}
const network = getNetwork(this.network)
// Create a claim transaction to be signed cooperatively via a key path spend
const claimTx = constructClaimTransaction(
[
{
...swapOutput,
keys,
preimage,
cooperative: true,
type: OutputType.Taproot,
txHash: lockupTx.getHash(),
},
],
address.toOutputScript(destinationAddress, network),
chainFee,
)
// Get the partial signature from Boltz
const claimUrl = `${this.httpUrl}/v2/swap/reverse/${createdResponse.id}/claim`
const claimReq = {
index: 0,
transaction: claimTx.toHex(),
preimage: preimage.toString('hex'),
pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'),
}
const boltzSigRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq)
if (!boltzSigRes.ok) {
return boltzSigRes
}
const boltzSig = boltzSigRes.data
// Aggregate the nonces
musig.aggregateNonces([
[boltzPublicKey, Buffer.from(boltzSig.pubNonce, 'hex')],
]);
// Initialize the session to sign the claim transaction
musig.initializeSession(
claimTx.hashForWitnessV1(
0,
[swapOutput.script],
[swapOutput.value],
Transaction.SIGHASH_DEFAULT,
),
);
// Add the partial signature from Boltz
musig.addPartial(
boltzPublicKey,
Buffer.from(boltzSig.partialSignature, 'hex'),
);
// Create our partial signature
musig.signPartial();
// Witness of the input to the aggregated signature
claimTx.ins[0].witness = [musig.aggregatePartials()];
// Broadcast the finalized transaction
const broadcastUrl = `${this.httpUrl}/v2/chain/BTC/transaction`
const broadcastReq = {
hex: claimTx.toHex(),
}
const broadcastResponse = await loggedPost<any>(this.log, broadcastUrl, broadcastReq)
if (!broadcastResponse.ok) {
return broadcastResponse
}
this.log('Transaction broadcasted', broadcastResponse.data)
const txId = claimTx.getId()
return { ok: true, txId }
}
}
const loggedPost = async <T>(log: PubLogger, url: string, req: any): Promise<{ ok: true, data: T } | { ok: false, error: string }> => {
try {
const { data } = await axios.post(url, req)
return { ok: true, data: data as T }
} catch (err: any) {
if (err.response?.data) {
log(ERROR, 'Error sending request', err.response.data)
return { ok: false, error: JSON.stringify(err.response.data) }
}
log(ERROR, 'Error sending request', err.message)
return { ok: false, error: err.message }
}
}
const loggedGet = async <T>(log: PubLogger, url: string): Promise<{ ok: true, data: T } | { ok: false, error: string }> => {
try {
const { data } = await axios.get(url)
return { ok: true, data: data as T }
} catch (err: any) {
if (err.response?.data) {
log(ERROR, 'Error getting request', err.response.data)
return { ok: false, error: err.response.data }
}
log(ERROR, 'Error getting request', err.message)
return { ok: false, error: err.message }
}
}
const getNetwork = (network: BTCNetwork): Network => {
switch (network) {
case 'mainnet':
return Networks.bitcoinMainnet
case 'testnet':
return Networks.bitcoinTestnet
case 'regtest':
return Networks.bitcoinRegtest
default:
throw new Error(`Invalid network: ${network}`)
}
}
// Submarine swaps currently not supported, keeping the code for future reference
/*
export class SubmarineSwaps {
settings: SettingsManager
log: PubLogger
constructor(settings: SettingsManager) {
this.settings = settings
this.log = getLogger({ component: 'SubmarineSwaps' })
}
SwapInvoice = async (invoice: string, paymentHash: string) => {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
this.log(ERROR, 'Swaps are not enabled');
return;
}
const keys = ECPairFactory(ecc).makeRandom()
const refundPublicKey = Buffer.from(keys.publicKey).toString('hex')
const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey }
const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/submarine`
this.log('Sending invoice swap request to', url);
const createdResponseRes = await loggedPost<InvoiceSwapResponse>(this.log, url, req)
if (!createdResponseRes.ok) {
return createdResponseRes
}
const createdResponse = createdResponseRes.data
this.log('Created invoice swap');
this.log(createdResponse);
const webSocket = new ws(`${this.settings.getSettings().swapsSettings.boltzWebSocketUrl}/v2/ws`)
const subReq = { op: 'subscribe', channel: 'swap.update', args: [createdResponse.id] }
webSocket.on('open', () => {
webSocket.send(JSON.stringify(subReq))
})
webSocket.on('message', async (rawMsg) => {
try {
await this.handleSwapInvoiceMessage(rawMsg, { createdResponse, info: { paymentHash, keys } }, () => webSocket.close())
} catch (err: any) {
this.log(ERROR, 'Error handling invoice WebSocket message', err.message)
webSocket.close()
return
}
});
}
handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: () => void) => {
const msg = JSON.parse(rawMsg.toString('utf-8'));
if (msg.event !== 'update') {
return;
}
this.log('Got invoice WebSocket update');
this.log(msg);
switch (msg.args[0].status) {
// "invoice.set" means Boltz is waiting for an onchain transaction to be sent
case 'invoice.set':
this.log('Waiting for onchain transaction');
return;
// Create a partial signature to allow Boltz to do a key path spend to claim the mainchain coins
case 'transaction.claim.pending':
await this.handleInvoiceClaimPending(data)
return;
case 'transaction.claimed':
this.log('Invoice swap successful');
closeWebSocket()
return;
}
}
handleInvoiceClaimPending = async (data: InvoiceSwapData) => {
this.log('Creating cooperative claim transaction');
const { createdResponse, info } = data
const { paymentHash, keys } = info
const { boltzHttpUrl } = this.settings.getSettings().swapsSettings
// Get the information request to create a partial signature
const url = `${boltzHttpUrl}/v2/swap/submarine/${createdResponse.id}/claim`
const claimTxDetailsRes = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url)
if (!claimTxDetailsRes.ok) {
return claimTxDetailsRes
}
const claimTxDetails = claimTxDetailsRes.data
// Verify that Boltz actually paid the invoice by comparing the preimage hash
// of the invoice to the SHA256 hash of the preimage from the response
const claimTxPreimageHash = createHash('sha256').update(Buffer.from(claimTxDetails.preimage, 'hex')).digest()
const invoicePreimageHash = Buffer.from(paymentHash, 'hex')
if (!claimTxPreimageHash.equals(invoicePreimageHash)) {
this.log(ERROR, 'Boltz provided invalid preimage');
return;
}
const boltzPublicKey = Buffer.from(createdResponse.claimPublicKey, 'hex')
// Create a musig signing instance
const musig = new Musig(await zkpInit(), keys, randomBytes(32), [
boltzPublicKey,
Buffer.from(keys.publicKey),
]);
// Tweak that musig with the Taptree of the swap scripts
TaprootUtils.tweakMusig(
musig,
SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree,
);
// Aggregate the nonces
musig.aggregateNonces([
[boltzPublicKey, Buffer.from(claimTxDetails.pubNonce, 'hex')],
]);
// Initialize the session to sign the transaction hash from the response
musig.initializeSession(
Buffer.from(claimTxDetails.transactionHash, 'hex'),
);
// Give our public nonce and the partial signature to Boltz
const claimUrl = `${boltzHttpUrl}/v2/swap/submarine/${createdResponse.id}/claim`
const claimReq = {
pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'),
partialSignature: Buffer.from(musig.signPartial()).toString('hex'),
}
const claimResponseRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq)
if (!claimResponseRes.ok) {
return claimResponseRes
}
const claimResponse = claimResponseRes.data
this.log('Claim response', claimResponse)
}
}
*/

View file

@ -0,0 +1,299 @@
import secp256k1ZkpModule from '@vulpemventures/secp256k1-zkp';
const zkpInit = (secp256k1ZkpModule as any).default || secp256k1ZkpModule;
import { initEccLib, Transaction, address } from 'bitcoinjs-lib';
// import bolt11 from 'bolt11';
import {
Musig, SwapTreeSerializer, TaprootUtils, detectSwap,
constructClaimTransaction, OutputType, constructRefundTransaction
} from 'boltz-core';
import { randomBytes, createHash } from 'crypto';
import { ECPairFactory, ECPairInterface } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import ws from 'ws';
import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js';
import { BTCNetwork } from '../../main/settings.js';
import { loggedGet, loggedPost, getNetwork } from './swapHelpers.js';
type TransactionSwapFees = {
percentage: number,
minerFees: {
claim: number,
lockup: number,
}
}
type TransactionSwapFeesRes = {
BTC?: {
BTC?: {
fees: TransactionSwapFees
}
}
}
type TransactionSwapResponse = {
id: string, refundPublicKey: string, swapTree: string,
timeoutBlockHeight: number, lockupAddress: string, invoice: string,
onchainAmount?: number
}
type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number }
export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo }
export class ReverseSwaps {
// settings: SettingsManager
private httpUrl: string
private wsUrl: string
log: PubLogger
private network: BTCNetwork
constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) {
this.httpUrl = httpUrl
this.wsUrl = wsUrl
this.network = network
this.log = getLogger({ component: 'ReverseSwaps' })
initEccLib(ecc)
}
getHttpUrl = () => {
return this.httpUrl
}
getWsUrl = () => {
return this.wsUrl
}
calculateFees = (fees: TransactionSwapFees, receiveAmount: number) => {
const pct = fees.percentage / 100
const minerFee = fees.minerFees.claim + fees.minerFees.lockup
const preFee = receiveAmount + minerFee
const fee = Math.ceil(preFee * pct)
const total = preFee + fee
return { total, fee, minerFee }
}
GetFees = async (): Promise<{ ok: true, fees: TransactionSwapFees, } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/swap/reverse`
const feesRes = await loggedGet<TransactionSwapFeesRes>(this.log, url)
if (!feesRes.ok) {
return { ok: false, error: feesRes.error }
}
if (!feesRes.data.BTC?.BTC?.fees) {
return { ok: false, error: 'No fees found for BTC to BTC swap' }
}
return { ok: true, fees: feesRes.data.BTC.BTC.fees }
}
SwapTransaction = async (txAmount: number): Promise<{ ok: true, createdResponse: TransactionSwapResponse, preimage: string, pubkey: string, privKey: string } | { ok: false, error: string }> => {
const preimage = randomBytes(32);
const keys = ECPairFactory(ecc).makeRandom()
if (!keys.privateKey) {
return { ok: false, error: 'Failed to generate keys' }
}
const url = `${this.httpUrl}/v2/swap/reverse`
const req: any = {
onchainAmount: txAmount,
to: 'BTC',
from: 'BTC',
claimPublicKey: Buffer.from(keys.publicKey).toString('hex'),
preimageHash: createHash('sha256').update(preimage).digest('hex'),
}
const createdResponseRes = await loggedPost<TransactionSwapResponse>(this.log, url, req)
if (!createdResponseRes.ok) {
return createdResponseRes
}
const createdResponse = createdResponseRes.data
this.log('Created transaction swap');
this.log(createdResponse);
return {
ok: true, createdResponse,
preimage: Buffer.from(preimage).toString('hex'),
pubkey: Buffer.from(keys.publicKey).toString('hex'),
privKey: Buffer.from(keys.privateKey).toString('hex')
}
}
SubscribeToTransactionSwap = async (data: TransactionSwapData, swapDone: (result: { ok: true, txId: string } | { ok: false, error: string }) => void) => {
const webSocket = new ws(`${this.wsUrl}/v2/ws`)
const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] }
webSocket.on('open', () => {
webSocket.send(JSON.stringify(subReq))
})
const interval = setInterval(() => {
webSocket.ping()
}, 30 * 1000)
let txId = "", isDone = false
const done = (failureReason?: string) => {
isDone = true
clearInterval(interval)
webSocket.close()
if (failureReason) {
swapDone({ ok: false, error: failureReason })
} else {
swapDone({ ok: true, txId })
}
}
webSocket.on('pong', () => {
this.log('WebSocket transaction swap pong received')
})
webSocket.on('error', (err) => {
this.log(ERROR, 'Error in WebSocket', err.message)
})
webSocket.on('close', () => {
if (!isDone) {
this.log(ERROR, 'WebSocket closed before swap was done');
done('WebSocket closed before swap was done')
}
})
webSocket.on('message', async (rawMsg) => {
try {
const result = await this.handleSwapTransactionMessage(rawMsg, data, done)
if (result) {
txId = result
}
} catch (err: any) {
this.log(ERROR, 'Error handling transaction WebSocket message', err.message)
done(err.message)
return
}
})
}
handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: (failureReason?: string) => void) => {
const msg = JSON.parse(rawMsg.toString('utf-8'));
if (msg.event !== 'update') {
return;
}
this.log('Got WebSocket update');
this.log(msg);
switch (msg.args[0].status) {
// "swap.created" means Boltz is waiting for the invoice to be paid
case 'swap.created':
this.log('Waiting invoice to be paid');
return;
// "transaction.mempool" means that Boltz sent an onchain transaction
case 'transaction.mempool':
const txIdRes = await this.handleTransactionMempool(data, msg.args[0].transaction.hex)
if (!txIdRes.ok) {
throw new Error(txIdRes.error)
}
return txIdRes.txId
case 'invoice.settled':
this.log('Transaction swap successful');
done()
return;
case 'invoice.expired':
case 'swap.expired':
case 'transaction.failed':
done(`swap ${data.createdResponse.id} failed with status ${msg.args[0].status}`)
return;
default:
this.log('Unknown swap transaction WebSocket message', msg)
return;
}
}
handleTransactionMempool = async (data: TransactionSwapData, txHex: string): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => {
this.log('Creating claim transaction');
const { createdResponse, info } = data
const { destinationAddress, keys, preimage, chainFee } = info
const boltzPublicKey = Buffer.from(
createdResponse.refundPublicKey,
'hex',
);
// Create a musig signing session and tweak it with the Taptree of the swap scripts
const musig = new Musig(await zkpInit(), keys, randomBytes(32), [
boltzPublicKey,
Buffer.from(keys.publicKey),
]);
const tweakedKey = TaprootUtils.tweakMusig(
musig,
// swap tree can either be a string or an object
SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree,
);
// Parse the lockup transaction and find the output relevant for the swap
const lockupTx = Transaction.fromHex(txHex);
const swapOutput = detectSwap(tweakedKey, lockupTx);
if (swapOutput === undefined) {
this.log(ERROR, 'No swap output found in lockup transaction');
return { ok: false, error: 'No swap output found in lockup transaction' }
}
const network = getNetwork(this.network)
// Create a claim transaction to be signed cooperatively via a key path spend
const claimTx = constructClaimTransaction(
[
{
...swapOutput,
keys,
preimage,
cooperative: true,
type: OutputType.Taproot,
txHash: lockupTx.getHash(),
},
],
address.toOutputScript(destinationAddress, network),
chainFee,
)
// Get the partial signature from Boltz
const claimUrl = `${this.httpUrl}/v2/swap/reverse/${createdResponse.id}/claim`
const claimReq = {
index: 0,
transaction: claimTx.toHex(),
preimage: preimage.toString('hex'),
pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'),
}
const boltzSigRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq)
if (!boltzSigRes.ok) {
return boltzSigRes
}
const boltzSig = boltzSigRes.data
// Aggregate the nonces
musig.aggregateNonces([
[boltzPublicKey, Buffer.from(boltzSig.pubNonce, 'hex')],
]);
// Initialize the session to sign the claim transaction
musig.initializeSession(
claimTx.hashForWitnessV1(
0,
[swapOutput.script],
[swapOutput.value],
Transaction.SIGHASH_DEFAULT,
),
);
// Add the partial signature from Boltz
musig.addPartial(
boltzPublicKey,
Buffer.from(boltzSig.partialSignature, 'hex'),
);
// Create our partial signature
musig.signPartial();
// Witness of the input to the aggregated signature
claimTx.ins[0].witness = [musig.aggregatePartials()];
// Broadcast the finalized transaction
const broadcastUrl = `${this.httpUrl}/v2/chain/BTC/transaction`
const broadcastReq = {
hex: claimTx.toHex(),
}
const broadcastResponse = await loggedPost<any>(this.log, broadcastUrl, broadcastReq)
if (!broadcastResponse.ok) {
return broadcastResponse
}
this.log('Transaction broadcasted', broadcastResponse.data)
const txId = claimTx.getId()
return { ok: true, txId }
}
}

View file

@ -0,0 +1,539 @@
import secp256k1ZkpModule from '@vulpemventures/secp256k1-zkp';
const zkpInit = (secp256k1ZkpModule as any).default || secp256k1ZkpModule;
// import bolt11 from 'bolt11';
import {
Musig, SwapTreeSerializer, TaprootUtils, constructRefundTransaction,
detectSwap, OutputType, targetFee
} from 'boltz-core';
import { randomBytes, createHash } from 'crypto';
import { ECPairFactory, ECPairInterface } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import { Transaction, address } from 'bitcoinjs-lib';
import ws from 'ws';
import { getLogger, PubLogger, ERROR } from '../../helpers/logger.js';
import { loggedGet, loggedPost, getNetwork } from './swapHelpers.js';
import { BTCNetwork } from '../../main/settings.js';
/* type InvoiceSwapFees = {
hash: string,
rate: number,
limits: {
maximal: number,
minimal: number,
maximalZeroConf: number
},
fees: {
percentage: number,
minerFees: number,
}
} */
type InvoiceSwapFees = {
percentage: number,
minerFees: number,
}
type InvoiceSwapFeesRes = {
BTC?: {
BTC?: {
fees: InvoiceSwapFees
}
}
}
type InvoiceSwapResponse = {
id: string, claimPublicKey: string, swapTree: string, timeoutBlockHeight: number,
expectedAmount: number, address: string
}
type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface }
export type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo }
export class SubmarineSwaps {
private httpUrl: string
private wsUrl: string
private network: BTCNetwork
log: PubLogger
constructor({ httpUrl, wsUrl, network }: { httpUrl: string, wsUrl: string, network: BTCNetwork }) {
this.httpUrl = httpUrl
this.wsUrl = wsUrl
this.network = network
this.log = getLogger({ component: 'SubmarineSwaps' })
}
getHttpUrl = () => {
return this.httpUrl
}
getWsUrl = () => {
return this.wsUrl
}
GetFees = async (): Promise<{ ok: true, fees: InvoiceSwapFees, } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/swap/submarine`
const feesRes = await loggedGet<InvoiceSwapFeesRes>(this.log, url)
if (!feesRes.ok) {
return { ok: false, error: feesRes.error }
}
if (!feesRes.data.BTC?.BTC?.fees) {
return { ok: false, error: 'No fees found for BTC to BTC swap' }
}
return { ok: true, fees: feesRes.data.BTC.BTC.fees }
}
SwapInvoice = async (invoice: string): Promise<{ ok: true, createdResponse: InvoiceSwapResponse, pubkey: string, privKey: string } | { ok: false, error: string }> => {
const keys = ECPairFactory(ecc).makeRandom()
if (!keys.privateKey) {
return { ok: false, error: 'Failed to generate keys' }
}
const refundPublicKey = Buffer.from(keys.publicKey).toString('hex')
const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey }
const url = `${this.httpUrl}/v2/swap/submarine`
this.log('Sending invoice swap request to', url);
const createdResponseRes = await loggedPost<InvoiceSwapResponse>(this.log, url, req)
if (!createdResponseRes.ok) {
return createdResponseRes
}
const createdResponse = createdResponseRes.data
this.log('Created invoice swap');
this.log(createdResponse);
return {
ok: true, createdResponse,
pubkey: refundPublicKey,
privKey: Buffer.from(keys.privateKey).toString('hex')
}
}
/**
* Get the lockup transaction for a swap from Boltz
*/
private getLockupTransaction = async (swapId: string): Promise<{ ok: true, data: { hex: string } } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/swap/submarine/${swapId}/transaction`
return await loggedGet<{ hex: string }>(this.log, url)
}
/**
* Get partial refund signature from Boltz for cooperative refund
*/
private getPartialRefundSignature = async (
swapId: string,
pubNonce: Buffer,
transaction: Transaction,
index: number
): Promise<{ ok: true, data: { pubNonce: string, partialSignature: string } } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/swap/submarine/${swapId}/refund`
const req = {
index,
pubNonce: pubNonce.toString('hex'),
transaction: transaction.toHex()
}
return await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, url, req)
}
/**
* Constructs a Taproot refund transaction (cooperative or uncooperative)
*/
private constructTaprootRefund = async (
swapId: string,
claimPublicKey: string,
swapTree: string,
timeoutBlockHeight: number,
lockupTx: Transaction,
privateKey: ECPairInterface,
refundAddress: string,
feePerVbyte: number,
cooperative: boolean = true,
allowUncooperativeFallback: boolean = true,
cooperativeErrorMessage?: string
): Promise<{
ok: true,
transaction: Transaction,
cooperativeError?: string
} | {
ok: false,
error: string
}> => {
this.log(`Constructing ${cooperative ? 'cooperative' : 'uncooperative'} Taproot refund for swap ${swapId}`)
const boltzPublicKey = Buffer.from(claimPublicKey, 'hex')
const swapTreeDeserialized = SwapTreeSerializer.deserializeSwapTree(swapTree)
// Create musig and tweak it
let musig = new Musig(await zkpInit(), privateKey, randomBytes(32), [
boltzPublicKey,
Buffer.from(privateKey.publicKey),
])
const tweakedKey = TaprootUtils.tweakMusig(musig, swapTreeDeserialized.tree)
// Detect the swap output in the lockup transaction
const swapOutput = detectSwap(tweakedKey, lockupTx)
if (!swapOutput) {
return { ok: false, error: 'Could not detect swap output in lockup transaction' }
}
const network = getNetwork(this.network)
// const decodedAddress = address.fromBech32(refundAddress)
const details = [
{
...swapOutput,
keys: privateKey,
cooperative,
type: OutputType.Taproot,
txHash: lockupTx.getHash(),
swapTree: swapTreeDeserialized,
internalKey: musig.getAggregatedPublicKey(),
}
]
const outputScript = address.toOutputScript(refundAddress, network)
// Construct the refund transaction: targetFee converts sat/vbyte rate to flat fee
const refundTx = targetFee(
feePerVbyte,
(fee) => constructRefundTransaction(
details,
outputScript,
cooperative ? 0 : timeoutBlockHeight,
fee,
true
)
)
if (!cooperative) {
return {
ok: true,
transaction: refundTx,
cooperativeError: cooperativeErrorMessage,
}
}
// For cooperative refund, get Boltz's partial signature
try {
musig = new Musig(await zkpInit(), privateKey, randomBytes(32), [
boltzPublicKey,
Buffer.from(privateKey.publicKey),
])
// Get the partial signature from Boltz
const boltzSigRes = await this.getPartialRefundSignature(
swapId,
Buffer.from(musig.getPublicNonce()),
refundTx,
0
)
if (!boltzSigRes.ok) {
this.log(ERROR, 'Failed to get Boltz partial signature')
if (!allowUncooperativeFallback) {
return { ok: false, error: `Failed to get Boltz partial signature: ${boltzSigRes.error}` }
}
this.log(ERROR, 'Falling back to uncooperative refund')
// Fallback to uncooperative refund
return await this.constructTaprootRefund(
swapId,
claimPublicKey,
swapTree,
timeoutBlockHeight,
lockupTx,
privateKey,
refundAddress,
feePerVbyte,
false,
allowUncooperativeFallback,
boltzSigRes.error
)
}
const boltzSig = boltzSigRes.data
// Aggregate nonces
musig.aggregateNonces([
[boltzPublicKey, Musig.parsePubNonce(boltzSig.pubNonce)],
])
// Tweak musig again after aggregating nonces
TaprootUtils.tweakMusig(musig, swapTreeDeserialized.tree)
// Initialize session and sign
musig.initializeSession(
TaprootUtils.hashForWitnessV1(
details,
refundTx,
0
)
)
musig.signPartial()
musig.addPartial(boltzPublicKey, Buffer.from(boltzSig.partialSignature, 'hex'))
// Set the witness to the aggregated signature
refundTx.ins[0].witness = [musig.aggregatePartials()]
return { ok: true, transaction: refundTx }
} catch (error: any) {
this.log(ERROR, 'Cooperative refund failed:', error.message)
if (!allowUncooperativeFallback) {
return { ok: false, error: `Cooperative refund failed: ${error.message}` }
}
// Fallback to uncooperative refund
return await this.constructTaprootRefund(
swapId,
claimPublicKey,
swapTree,
timeoutBlockHeight,
lockupTx,
privateKey,
refundAddress,
feePerVbyte,
false,
allowUncooperativeFallback,
error.message
)
}
}
/**
* Broadcasts a refund transaction
*/
private broadcastRefundTransaction = async (transaction: Transaction): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => {
const url = `${this.httpUrl}/v2/chain/BTC/transaction`
const req = { hex: transaction.toHex() }
const result = await loggedPost<{ id: string }>(this.log, url, req)
if (!result.ok) {
return result
}
return { ok: true, txId: result.data.id }
}
/**
* Refund a submarine swap
* @param swapId - The swap ID
* @param claimPublicKey - Boltz's claim public key
* @param swapTree - The swap tree
* @param timeoutBlockHeight - The timeout block height
* @param privateKey - The refund private key (hex string)
* @param refundAddress - The address to refund to
* @param currentHeight - The current block height
* @param lockupTxHex - The lockup transaction hex (optional, will fetch from Boltz if not provided)
* @param feePerVbyte - Fee rate in sat/vbyte (optional, will use default if not provided)
*/
RefundSwap = async (params: {
swapId: string,
claimPublicKey: string,
swapTree: string,
timeoutBlockHeight: number,
privateKeyHex: string,
refundAddress: string,
currentHeight: number,
lockupTxHex?: string,
feePerVbyte?: number,
allowEarlyRefund?: boolean
}): Promise<{ ok: true, publish: { done: false, txHex: string, txId: string } | { done: true, txId: string } } | { ok: false, error: string }> => {
const { swapId, claimPublicKey, swapTree, timeoutBlockHeight, privateKeyHex, refundAddress, currentHeight, lockupTxHex, feePerVbyte = 2, allowEarlyRefund = false } = params
this.log('Starting refund process for swap:', swapId)
// Get the lockup transaction (from parameter or fetch from Boltz)
let lockupTx: Transaction
if (lockupTxHex) {
this.log('Using provided lockup transaction hex')
lockupTx = Transaction.fromHex(lockupTxHex)
} else {
this.log('Fetching lockup transaction from Boltz')
const lockupTxRes = await this.getLockupTransaction(swapId)
if (!lockupTxRes.ok) {
return { ok: false, error: `Failed to get lockup transaction: ${lockupTxRes.error}` }
}
lockupTx = Transaction.fromHex(lockupTxRes.data.hex)
}
this.log('Lockup transaction retrieved:', lockupTx.getId())
const hasTimedOut = currentHeight >= timeoutBlockHeight
// For stuck swaps, only allow refund after timeout. For completed (failed) swaps,
// we may attempt a cooperative refund before timeout.
if (!hasTimedOut && !allowEarlyRefund) {
return {
ok: false,
error: `Swap has not timed out yet. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}`
}
}
if (hasTimedOut) {
this.log(`Swap has timed out. Current height: ${currentHeight}, timeout: ${timeoutBlockHeight}`)
} else {
this.log(`Swap has not timed out yet, attempting cooperative refund`)
}
// Parse the private key
const privateKey = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKeyHex, 'hex'))
// Construct the refund transaction (tries cooperative first, then falls back to uncooperative)
const refundTxRes = await this.constructTaprootRefund(
swapId,
claimPublicKey,
swapTree,
timeoutBlockHeight,
lockupTx,
privateKey,
refundAddress,
feePerVbyte,
true, // Try cooperative first
hasTimedOut // only allow uncooperative fallback once timeout has passed
)
if (!refundTxRes.ok) {
return { ok: false, error: refundTxRes.error }
}
const cooperative = !refundTxRes.cooperativeError
this.log(`Refund transaction constructed (${cooperative ? 'cooperative' : 'uncooperative'}):`, refundTxRes.transaction.getId())
if (!cooperative) {
return { ok: true, publish: { done: false, txHex: refundTxRes.transaction.toHex(), txId: refundTxRes.transaction.getId() } }
}
// Broadcast the refund transaction
const broadcastRes = await this.broadcastRefundTransaction(refundTxRes.transaction)
if (!broadcastRes.ok) {
return { ok: false, error: `Failed to broadcast refund transaction: ${broadcastRes.error}` }
}
this.log('Refund transaction broadcasted successfully:', broadcastRes.txId)
return { ok: true, publish: { done: true, txId: broadcastRes.txId } }
}
SubscribeToInvoiceSwap = (data: InvoiceSwapData, swapDone: (result: { ok: true } | { ok: false, error: string }) => void, waitingTx: () => void) => {
this.log("subscribing to invoice swap", { id: data.createdResponse.id })
const webSocket = new ws(`${this.wsUrl}/v2/ws`)
const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] }
webSocket.on('open', () => {
webSocket.send(JSON.stringify(subReq))
})
const interval = setInterval(() => {
webSocket.ping()
}, 30 * 1000)
let isDone = false
const done = (failureReason?: string) => {
isDone = true
clearInterval(interval)
webSocket.close()
if (failureReason) {
swapDone({ ok: false, error: failureReason })
} else {
swapDone({ ok: true })
}
}
webSocket.on('pong', () => {
this.log('WebSocket invoice swap pong received')
})
webSocket.on('error', (err) => {
this.log(ERROR, 'Error in WebSocket', err.message)
})
webSocket.on('close', () => {
if (!isDone) {
this.log(ERROR, 'WebSocket closed before swap was done');
done('WebSocket closed before swap was done')
}
})
webSocket.on('message', async (rawMsg) => {
try {
await this.handleSwapInvoiceMessage(rawMsg, data, done, waitingTx)
} catch (err: any) {
this.log(ERROR, 'Error handling invoice WebSocket message', err.message)
done(err.message)
return
}
});
return () => {
webSocket.close()
}
}
handleSwapInvoiceMessage = async (rawMsg: ws.RawData, data: InvoiceSwapData, closeWebSocket: (failureReason?: string) => void, waitingTx: () => void) => {
const msg = JSON.parse(rawMsg.toString('utf-8'));
if (msg.event !== 'update') {
return;
}
this.log('Got invoice WebSocket update');
this.log(msg);
switch (msg.args[0].status) {
// "invoice.set" means Boltz is waiting for an onchain transaction to be sent
case 'invoice.set':
this.log('Waiting for onchain transaction');
waitingTx()
return;
// Create a partial signature to allow Boltz to do a key path spend to claim the mainchain coins
case 'transaction.claim.pending':
await this.handleInvoiceClaimPending(data)
return;
case 'transaction.claimed':
this.log('Invoice swap successful');
closeWebSocket()
return;
case 'swap.expired':
case 'transaction.lockupFailed':
case 'invoice.failedToPay':
closeWebSocket(`swap ${data.createdResponse.id} failed with status ${msg.args[0].status}`)
return;
default:
this.log('Unknown swap invoice WebSocket message', msg)
return;
}
}
handleInvoiceClaimPending = async (data: InvoiceSwapData) => {
this.log('Creating cooperative claim transaction');
const { createdResponse, info } = data
const { paymentHash, keys } = info
// Get the information request to create a partial signature
const url = `${this.httpUrl}/v2/swap/submarine/${createdResponse.id}/claim`
const claimTxDetailsRes = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url)
if (!claimTxDetailsRes.ok) {
return claimTxDetailsRes
}
const claimTxDetails = claimTxDetailsRes.data
// Verify that Boltz actually paid the invoice by comparing the preimage hash
// of the invoice to the SHA256 hash of the preimage from the response
const claimTxPreimageHash = createHash('sha256').update(Buffer.from(claimTxDetails.preimage, 'hex')).digest()
const invoicePreimageHash = Buffer.from(paymentHash, 'hex')
if (!claimTxPreimageHash.equals(invoicePreimageHash)) {
this.log(ERROR, 'Boltz provided invalid preimage');
return;
}
const boltzPublicKey = Buffer.from(createdResponse.claimPublicKey, 'hex')
// Create a musig signing instance
const musig = new Musig(await zkpInit(), keys, randomBytes(32), [
boltzPublicKey,
Buffer.from(keys.publicKey),
]);
// Tweak that musig with the Taptree of the swap scripts
TaprootUtils.tweakMusig(
musig,
SwapTreeSerializer.deserializeSwapTree(createdResponse.swapTree).tree,
);
// Aggregate the nonces
musig.aggregateNonces([
[boltzPublicKey, Buffer.from(claimTxDetails.pubNonce, 'hex')],
]);
// Initialize the session to sign the transaction hash from the response
musig.initializeSession(
Buffer.from(claimTxDetails.transactionHash, 'hex'),
);
// Give our public nonce and the partial signature to Boltz
const claimUrl = `${this.httpUrl}/v2/swap/submarine/${createdResponse.id}/claim`
const claimReq = {
pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'),
partialSignature: Buffer.from(musig.signPartial()).toString('hex'),
}
const claimResponseRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq)
if (!claimResponseRes.ok) {
return claimResponseRes
}
const claimResponse = claimResponseRes.data
this.log('Claim response', claimResponse)
}
}

View file

@ -0,0 +1,50 @@
import axios from 'axios';
import { Network } from 'bitcoinjs-lib';
// import bolt11 from 'bolt11';
import {
Networks,
} from 'boltz-core';
import { PubLogger, ERROR } from '../../helpers/logger.js';
import { BTCNetwork } from '../../main/settings.js';
export const loggedPost = async <T>(log: PubLogger, url: string, req: any): Promise<{ ok: true, data: T } | { ok: false, error: string }> => {
try {
const { data } = await axios.post(url, req)
return { ok: true, data: data as T }
} catch (err: any) {
if (err.response?.data) {
log(ERROR, 'Error sending request', err.response.data)
return { ok: false, error: JSON.stringify(err.response.data) }
}
log(ERROR, 'Error sending request', err.message)
return { ok: false, error: err.message }
}
}
export const loggedGet = async <T>(log: PubLogger, url: string): Promise<{ ok: true, data: T } | { ok: false, error: string }> => {
try {
const { data } = await axios.get(url)
return { ok: true, data: data as T }
} catch (err: any) {
if (err.response?.data) {
log(ERROR, 'Error getting request', err.response.data)
return { ok: false, error: err.response.data }
}
log(ERROR, 'Error getting request', err.message)
return { ok: false, error: err.message }
}
}
export const getNetwork = (network: BTCNetwork): Network => {
switch (network) {
case 'mainnet':
return Networks.bitcoinMainnet
case 'testnet':
return Networks.bitcoinTestnet
case 'regtest':
return Networks.bitcoinRegtest
default:
throw new Error(`Invalid network: ${network}`)
}
}

View file

@ -0,0 +1,448 @@
import { ECPairFactory } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import { getLogger } from '../../helpers/logger.js';
import SettingsManager from '../../main/settingsManager.js';
import * as Types from '../../../../proto/autogenerated/ts/types.js';
import Storage from '../../storage/index.js';
import LND from '../lnd.js';
import { UserInvoicePayment } from '../../storage/entity/UserInvoicePayment.js';
import { ReverseSwaps, TransactionSwapData } from './reverseSwaps.js';
import { SubmarineSwaps, InvoiceSwapData } from './submarineSwaps.js';
import { InvoiceSwap } from '../../storage/entity/InvoiceSwap.js';
import { TransactionSwap } from '../../storage/entity/TransactionSwap.js';
export class Swaps {
settings: SettingsManager
revSwappers: Record<string, ReverseSwaps>
subSwappers: Record<string, SubmarineSwaps>
storage: Storage
lnd: LND
waitingSwaps: Record<string, boolean> = {}
log = getLogger({ component: 'swaps' })
constructor(settings: SettingsManager, storage: Storage) {
this.settings = settings
this.revSwappers = {}
this.subSwappers = {}
const network = settings.getSettings().lndSettings.network
const { boltzHttpUrl, boltzWebSocketUrl, boltsHttpUrlAlt, boltsWebSocketUrlAlt } = settings.getSettings().swapsSettings
if (boltzHttpUrl && boltzWebSocketUrl) {
this.revSwappers[boltzHttpUrl] = new ReverseSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network })
this.subSwappers[boltzHttpUrl] = new SubmarineSwaps({ httpUrl: boltzHttpUrl, wsUrl: boltzWebSocketUrl, network })
}
if (boltsHttpUrlAlt && boltsWebSocketUrlAlt) {
this.revSwappers[boltsHttpUrlAlt] = new ReverseSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network })
this.subSwappers[boltsHttpUrlAlt] = new SubmarineSwaps({ httpUrl: boltsHttpUrlAlt, wsUrl: boltsWebSocketUrlAlt, network })
}
this.storage = storage
}
SetLnd = (lnd: LND) => {
this.lnd = lnd
}
Stop = () => { }
GetKeys = (privateKey: string) => {
const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex'))
return keys
}
GetInvoiceSwapQuotes = async (appUserId: string, invoice: string): Promise<Types.InvoiceSwapQuote[]> => {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
const swappers = Object.values(this.subSwappers)
if (swappers.length === 0) {
throw new Error("No swap services available")
}
const res = await Promise.allSettled(swappers.map(sw => this.getInvoiceSwapQuote(sw, appUserId, invoice)))
const failures: string[] = []
const success: Types.InvoiceSwapQuote[] = []
for (const r of res) {
if (r.status === 'fulfilled') {
success.push(r.value)
} else {
failures.push(r.reason.message ? r.reason.message : r.reason.toString())
}
}
if (success.length === 0) {
throw new Error(failures.join("\n"))
}
return success
}
private mapInvoiceSwapQuote = (s: InvoiceSwap): Types.InvoiceSwapQuote => {
return {
swap_operation_id: s.swap_operation_id,
invoice: s.invoice,
invoice_amount_sats: s.invoice_amount,
address: s.address,
transaction_amount_sats: s.transaction_amount,
chain_fee_sats: s.chain_fee_sats,
service_fee_sats: 0,
service_url: s.service_url,
swap_fee_sats: s.swap_fee_sats,
tx_id: s.tx_id,
paid_at_unix: s.paid_at_unix || (s.tx_id ? 1 : 0),
expires_at_block_height: s.timeout_block_height,
}
}
ListInvoiceSwaps = async (appUserId: string): Promise<Types.InvoiceSwapsList> => {
const info = await this.lnd.GetInfo()
const currentBlockHeight = info.blockHeight
const completedSwaps = await this.storage.paymentStorage.ListCompletedInvoiceSwaps(appUserId)
const pendingSwaps = await this.storage.paymentStorage.ListPendingInvoiceSwaps(appUserId)
const quotes: Types.InvoiceSwapOperation[] = pendingSwaps.map(s => ({ quote: this.mapInvoiceSwapQuote(s) }))
const operations: Types.InvoiceSwapOperation[] = completedSwaps.map(s => ({
quote: this.mapInvoiceSwapQuote(s),
failure_reason: s.failure_reason,
completed_at_unix: s.completed_at_unix || 1,
}))
return {
current_block_height: currentBlockHeight,
swaps: operations.concat(quotes),
}
}
RefundInvoiceSwap = async (swapOperationId: string, satPerVByte: number, refundAddress: string, currentHeight: number): Promise<{ published: false, txHex: string, txId: string } | { published: true, txId: string }> => {
this.log("refunding invoice swap", { swapOperationId, satPerVByte, refundAddress, currentHeight })
const swap = await this.storage.paymentStorage.GetRefundableInvoiceSwap(swapOperationId)
if (!swap) {
throw new Error("Swap not found or already used")
}
const allowEarlyRefund = !!swap.failure_reason
const swapper = this.subSwappers[swap.service_url]
if (!swapper) {
throw new Error("swapper service not found")
}
const result = await swapper.RefundSwap({
swapId: swap.swap_quote_id,
claimPublicKey: swap.claim_public_key,
currentHeight,
privateKeyHex: swap.ephemeral_private_key,
refundAddress,
swapTree: swap.swap_tree,
timeoutBlockHeight: swap.timeout_block_height,
allowEarlyRefund,
feePerVbyte: satPerVByte,
lockupTxHex: swap.lockup_tx_hex,
})
if (!result.ok) {
throw new Error(result.error)
}
if (result.publish.done) {
return { published: true, txId: result.publish.txId }
}
return { published: false, txHex: result.publish.txHex, txId: result.publish.txId }
}
PayInvoiceSwap = async (appUserId: string, swapOpId: string, satPerVByte: number, payAddress: (address: string, amt: number) => Promise<{ txId: string }>): Promise<void> => {
this.log("paying invoice swap", { appUserId, swapOpId, satPerVByte })
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
if (!swapOpId) {
throw new Error("swap operation id is required")
}
if (!satPerVByte) {
throw new Error("sat per v byte is required")
}
const swap = await this.storage.paymentStorage.GetInvoiceSwap(swapOpId, appUserId)
if (!swap) {
throw new Error("swap not found")
}
const swapper = this.subSwappers[swap.service_url]
if (!swapper) {
throw new Error("swapper service not found")
}
if (this.waitingSwaps[swapOpId]) {
throw new Error("swap already in progress")
}
this.waitingSwaps[swapOpId] = true
const data = this.getInvoiceSwapData(swap)
let txId = ""
const close = swapper.SubscribeToInvoiceSwap(data, async (result) => {
if (result.ok) {
await this.storage.paymentStorage.FinalizeInvoiceSwap(swapOpId)
this.log("invoice swap completed", { swapOpId, txId })
} else {
await this.storage.paymentStorage.FailInvoiceSwap(swapOpId, result.error, txId)
this.log("invoice swap failed", { swapOpId, error: result.error })
}
}, () => payAddress(swap.address, swap.transaction_amount)
.then(res => { txId = res.txId })
.catch(err => { close(); this.log("error paying address", err.message || err) }))
}
ResumeInvoiceSwaps = async () => {
this.log("resuming invoice swaps")
const swaps = await this.storage.paymentStorage.ListUnfinishedInvoiceSwaps()
this.log("resuming", swaps.length, "invoice swaps")
for (const swap of swaps) {
try {
this.resumeInvoiceSwap(swap)
} catch (err: any) {
this.log("error resuming invoice swap", err.message || err)
}
}
}
private resumeInvoiceSwap = (swap: InvoiceSwap) => {
// const swap = await this.storage.paymentStorage.GetInvoiceSwap(swapOpId, appUserId)
if (!swap || !swap.tx_id || swap.used) {
throw new Error("swap to resume not found, or does not have a tx id")
}
const swapper = this.subSwappers[swap.service_url]
if (!swapper) {
throw new Error("swapper service not found")
}
const data = this.getInvoiceSwapData(swap)
swapper.SubscribeToInvoiceSwap(data, async (result) => {
if (result.ok) {
await this.storage.paymentStorage.FinalizeInvoiceSwap(swap.swap_operation_id)
this.log("invoice swap completed", { swapOpId: swap.swap_operation_id, txId: swap.tx_id })
} else {
await this.storage.paymentStorage.FailInvoiceSwap(swap.swap_operation_id, result.error)
this.log("invoice swap failed", { swapOpId: swap.swap_operation_id, error: result.error })
}
}, () => { throw new Error("swap tx already paid") })
}
private getInvoiceSwapData = (swap: InvoiceSwap) => {
return {
createdResponse: {
address: swap.address,
claimPublicKey: swap.claim_public_key,
id: swap.swap_quote_id,
swapTree: swap.swap_tree,
timeoutBlockHeight: swap.timeout_block_height,
expectedAmount: swap.transaction_amount,
},
info: {
keys: this.GetKeys(swap.ephemeral_private_key),
paymentHash: swap.payment_hash,
}
}
}
private async getInvoiceSwapQuote(swapper: SubmarineSwaps, appUserId: string, invoice: string): Promise<Types.InvoiceSwapQuote> {
const feesRes = await swapper.GetFees()
if (!feesRes.ok) {
throw new Error(feesRes.error)
}
const decoded = await this.lnd.DecodeInvoice(invoice)
const amt = decoded.numSatoshis
const fee = Math.ceil((feesRes.fees.percentage / 100) * amt) + feesRes.fees.minerFees
const res = await swapper.SwapInvoice(invoice)
if (!res.ok) {
throw new Error(res.error)
}
const newSwap = await this.storage.paymentStorage.AddInvoiceSwap({
app_user_id: appUserId,
swap_quote_id: res.createdResponse.id,
swap_tree: JSON.stringify(res.createdResponse.swapTree),
timeout_block_height: res.createdResponse.timeoutBlockHeight,
ephemeral_public_key: res.pubkey,
ephemeral_private_key: res.privKey,
invoice: invoice,
invoice_amount: amt,
transaction_amount: res.createdResponse.expectedAmount,
swap_fee_sats: fee,
chain_fee_sats: 0,
service_url: swapper.getHttpUrl(),
address: res.createdResponse.address,
claim_public_key: res.createdResponse.claimPublicKey,
payment_hash: decoded.paymentHash,
})
return {
swap_operation_id: newSwap.swap_operation_id,
invoice: invoice,
invoice_amount_sats: amt,
address: res.createdResponse.address,
transaction_amount_sats: res.createdResponse.expectedAmount,
chain_fee_sats: 0,
service_fee_sats: 0,
service_url: swapper.getHttpUrl(),
swap_fee_sats: fee,
tx_id: newSwap.tx_id,
paid_at_unix: newSwap.paid_at_unix,
expires_at_block_height: newSwap.timeout_block_height,
}
}
private mapTransactionSwapQuote = (s: TransactionSwap, getServiceFee: (amt: number) => number): Types.TransactionSwapQuote => {
const serviceFee = getServiceFee(s.invoice_amount)
return {
swap_operation_id: s.swap_operation_id,
transaction_amount_sats: s.transaction_amount,
invoice_amount_sats: s.invoice_amount,
chain_fee_sats: s.chain_fee_sats,
service_fee_sats: serviceFee,
swap_fee_sats: s.swap_fee_sats,
expires_at_block_height: s.timeout_block_height,
service_url: s.service_url,
paid_at_unix: s.paid_at_unix,
completed_at_unix: s.completed_at_unix,
}
}
ListTxSwaps = async (appUserId: string, payments: UserInvoicePayment[], newOp: (p: UserInvoicePayment) => Types.UserOperation | undefined, getServiceFee: (amt: number) => number): Promise<Types.TxSwapsList> => {
const completedSwaps = await this.storage.paymentStorage.ListCompletedTxSwaps(appUserId, payments)
const pendingSwaps = await this.storage.paymentStorage.ListPendingTransactionSwaps(appUserId)
const quotes: Types.TxSwapOperation[] = pendingSwaps.map(s => ({ quote: this.mapTransactionSwapQuote(s, getServiceFee) }))
const swaps: Types.TxSwapOperation[] = completedSwaps.map(s => ({
quote: this.mapTransactionSwapQuote(s.swap, getServiceFee),
operation_payment: s.payment ? newOp(s.payment) : undefined,
address_paid: s.swap.address_paid,
tx_id: s.swap.tx_id,
failure_reason: s.swap.failure_reason,
}))
return {
swaps: swaps.concat(quotes),
}
}
GetTxSwapQuotes = async (appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise<Types.TransactionSwapQuote[]> => {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
const swappers = Object.values(this.revSwappers)
if (swappers.length === 0) {
throw new Error("No swap services available")
}
const res = await Promise.allSettled(swappers.map(sw => this.getTxSwapQuote(sw, appUserId, amt, getServiceFee)))
const failures: string[] = []
const success: Types.TransactionSwapQuote[] = []
for (const r of res) {
if (r.status === 'fulfilled') {
success.push(r.value)
} else {
failures.push(r.reason.message ? r.reason.message : r.reason.toString())
}
}
if (success.length === 0) {
throw new Error(failures.join("\n"))
}
return success
}
private async getTxSwapQuote(swapper: ReverseSwaps, appUserId: string, amt: number, getServiceFee: (decodedAmt: number) => number): Promise<Types.TransactionSwapQuote> {
this.log("getting transaction swap quote")
const feesRes = await swapper.GetFees()
if (!feesRes.ok) {
throw new Error(feesRes.error)
}
const { claim, lockup } = feesRes.fees.minerFees
const minerFee = claim + lockup
const chainTotal = amt + minerFee
const res = await swapper.SwapTransaction(chainTotal)
if (!res.ok) {
throw new Error(res.error)
}
const decoded = await this.lnd.DecodeInvoice(res.createdResponse.invoice)
const swapFee = decoded.numSatoshis - chainTotal
const serviceFee = getServiceFee(decoded.numSatoshis)
const newSwap = await this.storage.paymentStorage.AddTransactionSwap({
app_user_id: appUserId,
swap_quote_id: res.createdResponse.id,
swap_tree: JSON.stringify(res.createdResponse.swapTree),
lockup_address: res.createdResponse.lockupAddress,
refund_public_key: res.createdResponse.refundPublicKey,
timeout_block_height: res.createdResponse.timeoutBlockHeight,
invoice: res.createdResponse.invoice,
invoice_amount: decoded.numSatoshis,
transaction_amount: chainTotal,
swap_fee_sats: swapFee,
chain_fee_sats: minerFee,
preimage: res.preimage,
ephemeral_private_key: res.privKey,
ephemeral_public_key: res.pubkey,
service_url: swapper.getHttpUrl(),
})
return {
swap_operation_id: newSwap.swap_operation_id,
swap_fee_sats: swapFee,
invoice_amount_sats: decoded.numSatoshis,
transaction_amount_sats: amt,
chain_fee_sats: minerFee,
service_fee_sats: serviceFee,
service_url: swapper.getHttpUrl(),
expires_at_block_height: res.createdResponse.timeoutBlockHeight,
paid_at_unix: newSwap.paid_at_unix,
completed_at_unix: newSwap.completed_at_unix,
}
}
async PayAddrWithSwap(appUserId: string, swapOpId: string, address: string, payInvoice: (invoice: string, amt: number) => Promise<void>) {
if (!this.settings.getSettings().swapsSettings.enableSwaps) {
throw new Error("Swaps are not enabled")
}
this.log("paying address with swap", { appUserId, swapOpId, address })
if (!swapOpId) {
throw new Error("request a swap quote before paying an external address")
}
const txSwap = await this.storage.paymentStorage.GetTransactionSwap(swapOpId, appUserId)
if (!txSwap) {
throw new Error("swap quote not found")
}
const info = await this.lnd.GetInfo()
if (info.blockHeight >= txSwap.timeout_block_height) {
throw new Error("swap timeout")
}
const swapper = this.revSwappers[txSwap.service_url]
if (!swapper) {
throw new Error("swapper service not found")
}
const keys = this.GetKeys(txSwap.ephemeral_private_key)
const data: TransactionSwapData = {
createdResponse: {
id: txSwap.swap_quote_id,
invoice: txSwap.invoice,
lockupAddress: txSwap.lockup_address,
refundPublicKey: txSwap.refund_public_key,
swapTree: txSwap.swap_tree,
timeoutBlockHeight: txSwap.timeout_block_height,
onchainAmount: txSwap.transaction_amount,
},
info: {
destinationAddress: address,
keys,
chainFee: txSwap.chain_fee_sats,
preimage: Buffer.from(txSwap.preimage, 'hex'),
}
}
// the swap and the invoice payment are linked, swap will not start until the invoice payment is started, and will not complete once the invoice payment is completed
let swapResult = { ok: false, error: "swap never completed" } as { ok: true, txId: string } | { ok: false, error: string }
swapper.SubscribeToTransactionSwap(data, result => {
swapResult = result
})
try {
await this.storage.paymentStorage.SetTransactionSwapPaid(swapOpId)
await payInvoice(txSwap.invoice, txSwap.invoice_amount)
if (!swapResult.ok) {
this.log("invoice payment successful, but swap failed")
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error)
throw new Error(swapResult.error)
}
this.log("swap completed successfully")
await this.storage.paymentStorage.FinalizeTransactionSwap(swapOpId, address, swapResult.txId)
} catch (err: any) {
if (swapResult.ok) {
this.log("failed to pay swap invoice, but swap completed successfully", swapResult.txId)
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, err.message)
} else {
this.log("failed to pay swap invoice and swap failed", swapResult.error)
await this.storage.paymentStorage.FailTransactionSwap(swapOpId, address, swapResult.error)
}
throw err
}
const networkFeesTotal = txSwap.chain_fee_sats + txSwap.swap_fee_sats
return {
txId: swapResult.txId,
network_fee: networkFeesTotal
}
}
}

View file

@ -5,9 +5,50 @@ import Storage from "../storage/index.js";
import * as Types from '../../../proto/autogenerated/ts/types.js' import * as Types from '../../../proto/autogenerated/ts/types.js'
import LND from "../lnd/lnd.js"; import LND from "../lnd/lnd.js";
import SettingsManager from "./settingsManager.js"; import SettingsManager from "./settingsManager.js";
import { Swaps } from "../lnd/swaps.js"; import { Swaps } from "../lnd/swaps/swaps.js";
import { defaultInvoiceExpiry } from "../storage/paymentStorage.js";
import { TrackedProvider } from "../storage/entity/TrackedProvider.js";
import { NodeInfo } from "../lnd/settings.js";
import { Invoice, Payment, OutputDetail, Transaction, Payment_PaymentStatus, Invoice_InvoiceState } from "../../../proto/lnd/lightning.js";
import { LiquidityProvider } from "./liquidityProvider.js";
/* type TrackedOperation = {
ts: number
amount: number
type: 'user' | 'root'
}
type AssetOperation = {
ts: number
amount: number
tracked?: TrackedOperation
}
type TrackedLndProvider = {
confirmedBalance: number
unconfirmedBalance: number
channelsBalace: number
payments: AssetOperation[]
invoices: AssetOperation[]
incomingTx: AssetOperation[]
outgoingTx: AssetOperation[]
}
type LndAssetProvider = {
pubkey: string
tracked?: TrackedLndProvider
}
type TrackedLiquidityProvider = {
balance: number
payments: AssetOperation[]
invoices: AssetOperation[]
}
type ProviderAssetProvider = {
pubkey: string
tracked?: TrackedLiquidityProvider
} */
const ROOT_OP = Types.TrackedOperationType.ROOT
const USER_OP = Types.TrackedOperationType.USER
export class AdminManager { export class AdminManager {
settings: SettingsManager settings: SettingsManager
liquidityProvider: LiquidityProvider | null = null
storage: Storage storage: Storage
log = getLogger({ component: "adminManager" }) log = getLogger({ component: "adminManager" })
adminNpub = "" adminNpub = ""
@ -40,6 +81,10 @@ export class AdminManager {
this.start() this.start()
} }
attachLiquidityProvider(liquidityProvider: LiquidityProvider) {
this.liquidityProvider = liquidityProvider
}
attachNostrReset(f: () => Promise<void>) { attachNostrReset(f: () => Promise<void>) {
this.nostrReset = f this.nostrReset = f
} }
@ -260,15 +305,64 @@ export class AdminManager {
} }
} }
async ListAdminSwaps(): Promise<Types.SwapsList> { async ListAdminInvoiceSwaps(): Promise<Types.InvoiceSwapsList> {
return this.swaps.ListSwaps("admin", [], p => undefined, amt => 0) return this.swaps.ListInvoiceSwaps("admin")
}
async GetAdminInvoiceSwapQuotes(req: Types.InvoiceSwapRequest): Promise<Types.InvoiceSwapQuoteList> {
const invoice = await this.lnd.NewInvoice(req.amount_sats, "Admin Swap", defaultInvoiceExpiry, { useProvider: false, from: 'system' })
const quotes = await this.swaps.GetInvoiceSwapQuotes("admin", invoice.payRequest)
return { quotes }
}
async PayAdminInvoiceSwap(req: Types.PayAdminInvoiceSwapRequest): Promise<Types.AdminInvoiceSwapResponse> {
const resolvedTxId = await new Promise<string>(res => {
this.swaps.PayInvoiceSwap("admin", req.swap_operation_id, req.sat_per_v_byte, async (addr, amt) => {
const tx = await this.lnd.PayAddress(addr, amt, req.sat_per_v_byte, "", { useProvider: false, from: 'system' })
this.log("paid admin invoice swap", { swapOpId: req.swap_operation_id, txId: tx.txid })
await this.storage.metricsStorage.AddRootOperation("chain_payment", tx.txid, amt, true)
// Fetch the full transaction hex for potential refunds
let lockupTxHex: string | undefined
let chainFeeSats = 0
try {
const txDetails = await this.lnd.GetTx(tx.txid)
chainFeeSats = Number(txDetails.totalFees)
lockupTxHex = txDetails.rawTxHex
} catch (err: any) {
this.log("Warning: Could not fetch transaction hex for refund purposes:", err.message)
}
await this.storage.paymentStorage.SetInvoiceSwapTxId(req.swap_operation_id, tx.txid, chainFeeSats, lockupTxHex)
this.log("saved admin swap txid", { swapOpId: req.swap_operation_id, txId: tx.txid })
res(tx.txid)
return { txId: tx.txid }
})
})
return { tx_id: resolvedTxId }
}
async RefundAdminInvoiceSwap(req: Types.RefundAdminInvoiceSwapRequest): Promise<Types.AdminInvoiceSwapResponse> {
const info = await this.lnd.GetInfo()
const currentHeight = info.blockHeight
const address = await this.lnd.NewAddress(Types.AddressType.WITNESS_PUBKEY_HASH, { useProvider: false, from: 'system' })
const result = await this.swaps.RefundInvoiceSwap(req.swap_operation_id, req.sat_per_v_byte, address.address, currentHeight)
if (result.published) {
return { tx_id: result.txId }
}
await this.lnd.PublishTransaction(result.txHex)
return { tx_id: result.txId }
}
async ListAdminTxSwaps(): Promise<Types.TxSwapsList> {
return this.swaps.ListTxSwaps("admin", [], p => undefined, amt => 0)
} }
async GetAdminTransactionSwapQuotes(req: Types.TransactionSwapRequest): Promise<Types.TransactionSwapQuoteList> { async GetAdminTransactionSwapQuotes(req: Types.TransactionSwapRequest): Promise<Types.TransactionSwapQuoteList> {
const quotes = await this.swaps.GetTxSwapQuotes("admin", req.transaction_amount_sats, () => 0) const quotes = await this.swaps.GetTxSwapQuotes("admin", req.transaction_amount_sats, () => 0)
return { quotes } return { quotes }
} }
async PayAdminTransactionSwap(req: Types.PayAdminTransactionSwapRequest): Promise<Types.AdminSwapResponse> { async PayAdminTransactionSwap(req: Types.PayAdminTransactionSwapRequest): Promise<Types.AdminTxSwapResponse> {
const routingFloor = this.settings.getSettings().lndSettings.routingFeeFloor const routingFloor = this.settings.getSettings().lndSettings.routingFeeFloor
const routingLimit = this.settings.getSettings().lndSettings.routingFeeLimitBps / 10000 const routingLimit = this.settings.getSettings().lndSettings.routingFeeLimitBps / 10000
@ -282,6 +376,222 @@ export class AdminManager {
network_fee: swap.network_fee, network_fee: swap.network_fee,
} }
} }
async GetAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq): Promise<Types.AssetsAndLiabilities> {
const providers = await this.storage.liquidityStorage.GetTrackedProviders()
const lnds: Types.LndAssetProvider[] = []
const liquidityProviders: Types.LiquidityAssetProvider[] = []
for (const provider of providers) {
if (provider.provider_type === 'lnd') {
const lndEntry = await this.GetLndAssetsAndLiabilities(req, provider)
lnds.push(lndEntry)
} else if (provider.provider_type === 'lnPub') {
const liquidityEntry = await this.GetProviderAssetsAndLiabilities(req, provider)
liquidityProviders.push(liquidityEntry)
}
}
const usersBalance = await this.storage.paymentStorage.GetTotalUsersBalance(true)
return {
users_balance: usersBalance,
lnds,
liquidity_providers: liquidityProviders,
}
}
async GetProviderAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq, provider: TrackedProvider): Promise<Types.LiquidityAssetProvider> {
if (!this.liquidityProvider) {
throw new Error("liquidity provider not attached")
}
if (this.liquidityProvider.GetProviderPubkey() !== provider.provider_pubkey) {
return { pubkey: provider.provider_pubkey, tracked: undefined }
}
const providerOps = await this.liquidityProvider.GetOperations(req.limit_providers || 100)
// we only care about invoices cuz they are the only ops we can generate with a provider
const invoices: Types.AssetOperation[] = []
const payments: Types.AssetOperation[] = []
for (const op of providerOps.latestIncomingInvoiceOperations.operations) {
const assetOp = await this.GetProviderInvoiceAssetOperation(op)
invoices.push(assetOp)
}
for (const op of providerOps.latestOutgoingInvoiceOperations.operations) {
const assetOp = await this.GetProviderPaymentAssetOperation(op)
payments.push(assetOp)
}
const balance = await this.liquidityProvider.GetUserState()
return {
pubkey: provider.provider_pubkey,
tracked: {
balance: balance.status === 'OK' ? balance.balance : 0,
payments,
invoices,
}
}
}
async GetProviderInvoiceAssetOperation(op: Types.UserOperation): Promise<Types.AssetOperation> {
const ts = Number(op.paidAtUnix)
const amount = Number(op.amount)
const invoice = op.identifier
const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(invoice)
if (userInvoice) {
const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice", invoice)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async GetProviderPaymentAssetOperation(op: Types.UserOperation): Promise<Types.AssetOperation> {
const ts = Number(op.paidAtUnix)
const amount = Number(op.amount)
const invoice = op.identifier
const userInvoice = await this.storage.paymentStorage.GetPaymentOwner(invoice)
if (userInvoice) {
const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice_payment", invoice)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async GetLndAssetsAndLiabilities(req: Types.AssetsAndLiabilitiesReq, provider: TrackedProvider): Promise<Types.LndAssetProvider> {
const info = await this.lnd.GetInfo()
if (provider.provider_pubkey !== info.identityPubkey) {
return { pubkey: provider.provider_pubkey, tracked: undefined }
}
const latestLndPayments = await this.lnd.GetAllPayments(req.limit_payments || 50)
const payments: Types.AssetOperation[] = []
for (const payment of latestLndPayments.payments) {
if (payment.status !== Payment_PaymentStatus.SUCCEEDED) {
continue
}
const assetOp = await this.GetPaymentAssetOperation(payment)
payments.push(assetOp)
}
const invoices: Types.AssetOperation[] = []
const paidInvoices = await this.lnd.GetAllInvoices(req.limit_invoices || 100)
for (const invoiceEntry of paidInvoices.invoices) {
if (invoiceEntry.state !== Invoice_InvoiceState.SETTLED) {
continue
}
const assetOp = await this.GetInvoiceAssetOperation(invoiceEntry)
invoices.push(assetOp)
}
const latestLndTransactions = await this.lnd.GetTransactions(info.blockHeight)
const txOuts: Types.AssetOperation[] = []
const txIns: Types.AssetOperation[] = []
for (const transaction of latestLndTransactions.transactions) {
for (const output of transaction.outputDetails) {
if (output.isOurAddress) {
const assetOp = await this.GetTxOutAssetOperation(transaction, output)
txOuts.push(assetOp)
}
}
// we only produce TXs with a single output
const input = transaction.previousOutpoints.find(p => p.isOurOutput)
if (input) {
const assetOp = await this.GetTxInAssetOperation(transaction)
txIns.push(assetOp)
}
}
const balance = await this.lnd.GetBalance()
const channelsBalance = balance.channelsBalance.reduce((acc, c) => acc + Number(c.localBalanceSats), 0)
return {
pubkey: provider.provider_pubkey,
tracked: {
confirmed_balance: Number(balance.confirmedBalance),
unconfirmed_balance: Number(balance.unconfirmedBalance),
channels_balance: channelsBalance,
payments,
invoices,
incoming_tx: txOuts, // tx outputs, are incoming sats
outgoing_tx: txIns, // tx inputs, are outgoing sats
}
}
}
async GetPaymentAssetOperation(payment: Payment): Promise<Types.AssetOperation> {
const invoice = payment.paymentRequest
const userInvoice = await this.storage.paymentStorage.GetPaymentOwner(invoice)
const ts = Number(payment.creationTimeNs / (BigInt(1000_000_000)))
const amount = Number(payment.valueSat)
if (userInvoice) {
const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice_payment", invoice)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async GetInvoiceAssetOperation(invoiceEntry: Invoice): Promise<Types.AssetOperation> {
const invoice = invoiceEntry.paymentRequest
const ts = Number(invoiceEntry.settleDate)
const amount = Number(invoiceEntry.amtPaidSat)
const userInvoice = await this.storage.paymentStorage.GetInvoiceOwner(invoice)
if (userInvoice) {
const tracked: Types.TrackedOperation = { ts: userInvoice.paid_at_unix, amount: userInvoice.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootOperation("invoice", invoice)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async GetTxInAssetOperation(tx: Transaction): Promise<Types.AssetOperation> {
const ts = Number(tx.timeStamp)
const amount = Number(tx.amount)
const userOp = await this.storage.paymentStorage.GetTxHashPaymentOwner(tx.txHash)
if (userOp) {
// user transaction payments are actually deprecated from lnd, but we keep this for consstency
const tracked: Types.TrackedOperation = { ts: userOp.paid_at_unix, amount: userOp.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootOperation("chain_payment", tx.txHash)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async GetTxOutAssetOperation(tx: Transaction, output: OutputDetail): Promise<Types.AssetOperation> {
const ts = Number(tx.timeStamp)
const amount = Number(output.amount)
const outputIndex = Number(output.outputIndex)
const userOp = await this.storage.paymentStorage.GetAddressReceivingTransactionOwner(output.address, tx.txHash)
if (userOp) {
const tracked: Types.TrackedOperation = { ts: userOp.paid_at_unix, amount: userOp.paid_amount, type: USER_OP }
return { ts, amount, tracked }
}
const rootOp = await this.storage.metricsStorage.GetRootAddressTransaction(output.address, tx.txHash, outputIndex)
if (rootOp) {
const tracked: Types.TrackedOperation = { ts: rootOp.at_unix, amount: rootOp.operation_amount, type: ROOT_OP }
return { ts, amount, tracked }
}
return { ts, amount, tracked: undefined }
}
async BumpTx(req: Types.BumpTx): Promise<void> {
await this.lnd.BumpFee(req.txid, req.output_index, req.sat_per_vbyte)
}
} }
const getDataPath = (dataDir: string, dataPath: string) => { const getDataPath = (dataDir: string, dataPath: string) => {

View file

@ -66,7 +66,7 @@ export default class {
const appUser = await this.storage.applicationStorage.GetAppUserFromUser(app, user.user_id) const appUser = await this.storage.applicationStorage.GetAppUserFromUser(app, user.user_id)
if (!appUser) { if (!appUser) {
throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing throw new Error("app user not found")
} }
const nostrSettings = this.settings.getSettings().nostrRelaySettings const nostrSettings = this.settings.getSettings().nostrRelaySettings
const { max, serviceFeeFloor, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats) const { max, serviceFeeFloor, serviceFeeBps } = this.applicationManager.paymentManager.GetMaxPayableInvoice(user.balance_sats)
@ -82,7 +82,8 @@ export default class {
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }),
nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: appUser.identifier, relay: nostrSettings.relays[0] }),
callback_url: appUser.callback_url, callback_url: appUser.callback_url,
bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl,
topic_id: appUser.topic_id
} }
} }
@ -131,12 +132,12 @@ export default class {
} }
this.log("Found", toDelete.length, "inactive users to delete") this.log("Found", toDelete.length, "inactive users to delete")
// await this.RemoveUsers(toDelete) await this.LockUsers(toDelete.map(u => u.userId))
} }
async CleanupNeverActiveUsers() { async CleanupNeverActiveUsers() {
this.log("Cleaning up never active users") this.log("Cleaning up never active users")
const inactiveUsers = await this.storage.userStorage.GetInactiveUsers(30) const inactiveUsers = await this.storage.userStorage.GetInactiveUsers(90)
const toDelete: { userId: string, appUserIds: string[] }[] = [] const toDelete: { userId: string, appUserIds: string[] }[] = []
for (const u of inactiveUsers) { for (const u of inactiveUsers) {
const user = await this.storage.userStorage.GetUser(u.user_id) const user = await this.storage.userStorage.GetUser(u.user_id)
@ -160,13 +161,26 @@ export default class {
} }
this.log("Found", toDelete.length, "never active users to delete") this.log("Found", toDelete.length, "never active users to delete")
// await this.RemoveUsers(toDelete) TODO: activate deletion await this.RemoveUsers(toDelete)
}
async LockUsers(toLock: string[]) {
this.log("Locking", toLock.length, "users")
for (const userId of toLock) {
await this.storage.userStorage.BanUser(userId)
}
this.log("Locked users")
} }
async RemoveUsers(toDelete: { userId: string, appUserIds: string[] }[]) { async RemoveUsers(toDelete: { userId: string, appUserIds: string[] }[]) {
this.log("Deleting", toDelete.length, "inactive users") this.log("Deleting", toDelete.length, "inactive users")
for (let i = 0; i < toDelete.length; i++) { for (let i = 0; i < toDelete.length; i++) {
const { userId, appUserIds } = toDelete[i] const { userId, appUserIds } = toDelete[i]
const user = await this.storage.userStorage.FindUser(userId)
if (!user || user.balance_sats > 0) {
if (user) this.log("Skipping user", userId, "has balance", user.balance_sats)
continue
}
this.log("Deleting user", userId, "progress", i + 1, "/", toDelete.length) this.log("Deleting user", userId, "progress", i + 1, "/", toDelete.length)
await this.storage.StartTransaction(async tx => { await this.storage.StartTransaction(async tx => {
for (const appUserId of appUserIds) { for (const appUserId of appUserIds) {
@ -174,11 +188,16 @@ export default class {
await this.storage.offerStorage.DeleteUserOffers(appUserId, tx) await this.storage.offerStorage.DeleteUserOffers(appUserId, tx)
await this.storage.debitStorage.RemoveUserDebitAccess(appUserId, tx) await this.storage.debitStorage.RemoveUserDebitAccess(appUserId, tx)
await this.storage.applicationStorage.RemoveAppUserDevices(appUserId, tx) await this.storage.applicationStorage.RemoveAppUserDevices(appUserId, tx)
} }
await this.storage.paymentStorage.RemoveUserInvoices(userId, tx) await this.storage.paymentStorage.RemoveUserInvoices(userId, tx)
await this.storage.productStorage.RemoveUserProducts(userId, tx) await this.storage.productStorage.RemoveUserProducts(userId, tx)
await this.storage.paymentStorage.RemoveUserEphemeralKeys(userId, tx) await this.storage.paymentStorage.RemoveUserEphemeralKeys(userId, tx)
await this.storage.paymentStorage.RemoveUserInvoicePayments(userId, tx)
await this.storage.paymentStorage.RemoveUserTransactionPayments(userId, tx)
await this.storage.paymentStorage.RemoveUserToUserPayments(userId, tx)
await this.storage.paymentStorage.RemoveUserReceivingAddresses(userId, tx)
await this.storage.userStorage.DeleteUserAccess(userId, tx)
await this.storage.applicationStorage.RemoveAppUsersAndBaseUsers(appUserIds, userId, tx)
}) })
} }
this.log("Cleaned up inactive users") this.log("Cleaned up inactive users")

View file

@ -169,7 +169,8 @@ export default class {
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }),
nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: u.identifier, relay: nostrSettings.relays[0] }),
callback_url: u.callback_url, callback_url: u.callback_url,
bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl,
topic_id: u.topic_id
}, },
max_withdrawable: max max_withdrawable: max
@ -227,7 +228,8 @@ export default class {
ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }), ndebit: ndebitEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }),
nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }), nmanage: nmanageEncode({ pubkey: app.nostr_public_key!, pointer: user.identifier, relay: nostrSettings.relays[0] }),
callback_url: user.callback_url, callback_url: user.callback_url,
bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl bridge_url: this.settings.getSettings().serviceSettings.bridgeUrl,
topic_id: user.topic_id
}, },
} }
} }
@ -239,6 +241,8 @@ export default class {
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, { const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) } ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
}) })
// Refresh appUser balance from DB so notification has accurate latest_balance
appUser.user.balance_sats = paid.latest_balance
this.notifyAppUserPayment(appUser, paid.operation) this.notifyAppUserPayment(appUser, paid.operation)
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats") getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
return paid return paid

View file

@ -153,13 +153,14 @@ export class DebitManager {
} }
notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => { notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
this.logger("✅ [DEBIT REQUEST] Payment successful, sending OK response to", event.pub.slice(0, 16) + "...", "for event", event.id.slice(0, 16) + "...")
this.sendDebitResponse(debitRes, event) this.sendDebitResponse(debitRes, event)
} }
sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => { sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
this.logger("📤 [DEBIT RESPONSE] Sending Kind 21002 response:", JSON.stringify(debitRes), "to", event.pub.slice(0, 16) + "...")
const e = newNdebitResponse(JSON.stringify(debitRes), event) const e = newNdebitResponse(JSON.stringify(debitRes), event)
this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } })
} }
payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => { payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => {

View file

@ -72,6 +72,7 @@ export default class {
this.unlocker = unlocker this.unlocker = unlocker
const updateProviderBalance = (b: number) => this.storage.liquidityStorage.IncrementTrackedProviderBalance('lnPub', settings.getSettings().liquiditySettings.liquidityProviderPub, b) const updateProviderBalance = (b: number) => this.storage.liquidityStorage.IncrementTrackedProviderBalance('lnPub', settings.getSettings().liquiditySettings.liquidityProviderPub, b)
this.liquidityProvider = new LiquidityProvider(() => this.settings.getSettings().liquiditySettings, this.utils, this.invoicePaidCb, updateProviderBalance) this.liquidityProvider = new LiquidityProvider(() => this.settings.getSettings().liquiditySettings, this.utils, this.invoicePaidCb, updateProviderBalance)
adminManager.attachLiquidityProvider(this.liquidityProvider)
this.rugPullTracker = new RugPullTracker(this.storage, this.liquidityProvider) this.rugPullTracker = new RugPullTracker(this.storage, this.liquidityProvider)
const lndGetSettings = () => ({ const lndGetSettings = () => ({
lndSettings: settings.getSettings().lndSettings, lndSettings: settings.getSettings().lndSettings,
@ -161,19 +162,23 @@ export default class {
NewBlockHandler = async (height: number, skipMetrics?: boolean) => { NewBlockHandler = async (height: number, skipMetrics?: boolean) => {
let confirmed: (PendingTx & { confs: number; })[] let confirmed: (PendingTx & { confs: number; })[]
let log = getLogger({}) let log = getLogger({})
log("NewBlockHandler called", JSON.stringify({ height, skipMetrics }))
this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height) this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height)
.catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err)) .catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err))
this.storage.paymentStorage.DeleteExpiredInvoiceSwaps(height)
.catch(err => log(ERROR, "failed to delete expired invoice swaps", err.message || err))
try { try {
const balanceEvents = await this.paymentManager.GetLndBalance() const balanceEvents = await this.paymentManager.GetLndBalance()
if (!skipMetrics) { if (!skipMetrics) {
await this.metricsManager.NewBlockCb(height, balanceEvents) await this.metricsManager.NewBlockCb(height, balanceEvents)
} }
confirmed = await this.paymentManager.CheckNewlyConfirmedTxs(height) confirmed = await this.paymentManager.CheckNewlyConfirmedTxs()
await this.liquidityManager.onNewBlock() await this.liquidityManager.onNewBlock()
} catch (err: any) { } catch (err: any) {
log(ERROR, "failed to check transactions after new block", err.message || err) log(ERROR, "failed to check transactions after new block", err.message || err)
return return
} }
log("NewBlockHandler new confirmed transactions", confirmed.length)
await Promise.all(confirmed.map(async c => { await Promise.all(confirmed.map(async c => {
if (c.type === 'outgoing') { if (c.type === 'outgoing') {
await this.storage.paymentStorage.UpdateUserTransactionPayment(c.tx.serial_id, { confs: c.confs }) await this.storage.paymentStorage.UpdateUserTransactionPayment(c.tx.serial_id, { confs: c.confs })
@ -208,6 +213,7 @@ export default class {
addressPaidCb: AddressPaidCb = (txOutput, address, amount, used, broadcastHeight) => { addressPaidCb: AddressPaidCb = (txOutput, address, amount, used, broadcastHeight) => {
return this.storage.StartTransaction(async tx => { return this.storage.StartTransaction(async tx => {
getLogger({})("addressPaidCb called", JSON.stringify({ txOutput, address, amount, used, broadcastHeight }))
// On-chain payments not supported when bypass is enabled // On-chain payments not supported when bypass is enabled
if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) { if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) {
getLogger({})("addressPaidCb called but USE_ONLY_LIQUIDITY_PROVIDER is enabled, ignoring") getLogger({})("addressPaidCb called but USE_ONLY_LIQUIDITY_PROVIDER is enabled, ignoring")
@ -419,13 +425,36 @@ export default class {
if (devices.length === 0 || !app.nostr_public_key || !app.nostr_private_key || !appUser.nostr_public_key) { if (devices.length === 0 || !app.nostr_public_key || !app.nostr_private_key || !appUser.nostr_public_key) {
return return
} }
const tokens = devices.map(d => d.firebase_messaging_token) const tokens = devices.map(d => d.firebase_messaging_token)
const ck = nip44.getConversationKey(Buffer.from(app.nostr_private_key, 'hex'), appUser.nostr_public_key) const ck = nip44.getConversationKey(Buffer.from(app.nostr_private_key, 'hex'), appUser.nostr_public_key)
const j = JSON.stringify(op)
let payloadToEncrypt: Types.PushNotificationPayload;
if (op.inbound) {
payloadToEncrypt = {
data: {
type: Types.PushNotificationPayload_data_type.RECEIVED_OPERATION,
received_operation: op
}
}
} else {
payloadToEncrypt = {
data: {
type: Types.PushNotificationPayload_data_type.SENT_OPERATION,
sent_operation: op
}
}
}
const j = JSON.stringify(payloadToEncrypt)
const encrypted = nip44.encrypt(j, ck) const encrypted = nip44.encrypt(j, ck)
const encryptedData: { encrypted: string, app_npub_hex: string } = { encrypted, app_npub_hex: app.nostr_public_key }
const envelope: Types.PushNotificationEnvelope = {
topic_id: appUser.topic_id,
app_npub_hex: app.nostr_public_key,
encrypted_payload: encrypted
}
const notification: ShockPushNotification = { const notification: ShockPushNotification = {
message: JSON.stringify(encryptedData), message: JSON.stringify(envelope),
body, body,
title title
} }

View file

@ -11,7 +11,7 @@ import { AdminManager } from "./adminManager.js"
import SettingsManager from "./settingsManager.js" import SettingsManager from "./settingsManager.js"
import { LoadStorageSettingsFromEnv } from "../storage/index.js" import { LoadStorageSettingsFromEnv } from "../storage/index.js"
import { NostrSender } from "../nostr/sender.js" import { NostrSender } from "../nostr/sender.js"
import { Swaps } from "../lnd/swaps.js" import { Swaps } from "../lnd/swaps/swaps.js"
export type AppData = { export type AppData = {
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
@ -79,6 +79,7 @@ export const initMainHandler = async (log: PubLogger, settingsManager: SettingsM
await mainHandler.paymentManager.CleanupOldUnpaidInvoices() await mainHandler.paymentManager.CleanupOldUnpaidInvoices()
await mainHandler.appUserManager.CleanupInactiveUsers() await mainHandler.appUserManager.CleanupInactiveUsers()
await mainHandler.appUserManager.CleanupNeverActiveUsers() await mainHandler.appUserManager.CleanupNeverActiveUsers()
await swaps.ResumeInvoiceSwaps()
await mainHandler.paymentManager.watchDog.Start() await mainHandler.paymentManager.watchDog.Start()
return { mainHandler, apps, localProviderClient, wizard, adminManager } return { mainHandler, apps, localProviderClient, wizard, adminManager }
} }

View file

@ -277,14 +277,14 @@ export class LiquidityProvider {
return res return res
} }
GetOperations = async () => { GetOperations = async (max = 200) => {
if (!this.IsReady()) { if (!this.IsReady()) {
throw new Error("liquidity provider is not ready yet, disabled or unreachable") throw new Error("liquidity provider is not ready yet, disabled or unreachable")
} }
const res = await this.client.GetUserOperations({ const res = await this.client.GetUserOperations({
latestIncomingInvoice: { ts: 0, id: 0 }, latestOutgoingInvoice: { ts: 0, id: 0 }, latestIncomingInvoice: { ts: 0, id: 0 }, latestOutgoingInvoice: { ts: 0, id: 0 },
latestIncomingTx: { ts: 0, id: 0 }, latestOutgoingTx: { ts: 0, id: 0 }, latestIncomingUserToUserPayment: { ts: 0, id: 0 }, latestIncomingTx: { ts: 0, id: 0 }, latestOutgoingTx: { ts: 0, id: 0 }, latestIncomingUserToUserPayment: { ts: 0, id: 0 },
latestOutgoingUserToUserPayment: { ts: 0, id: 0 }, max_size: 200 latestOutgoingUserToUserPayment: { ts: 0, id: 0 }, max_size: max
}) })
if (res.status === 'ERROR') { if (res.status === 'ERROR') {
this.log("error getting operations", res.reason) this.log("error getting operations", res.reason)

View file

@ -18,7 +18,7 @@ import { LiquidityManager } from './liquidityManager.js'
import { Utils } from '../helpers/utilsWrapper.js' import { Utils } from '../helpers/utilsWrapper.js'
import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js' import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js'
import SettingsManager from './settingsManager.js' import SettingsManager from './settingsManager.js'
import { Swaps, TransactionSwapData } from '../lnd/swaps.js' import { Swaps } from '../lnd/swaps/swaps.js'
import { Transaction, OutputDetail } from '../../../proto/lnd/lightning.js' import { Transaction, OutputDetail } from '../../../proto/lnd/lightning.js'
import { LndAddress } from '../lnd/lnd.js' import { LndAddress } from '../lnd/lnd.js'
import Metrics from '../metrics/index.js' import Metrics from '../metrics/index.js'
@ -201,24 +201,11 @@ export default class {
} else { } else {
log("no missed chain transactions found") log("no missed chain transactions found")
} }
await this.reprocessStuckPendingTx(log, currentHeight)
} catch (err: any) { } catch (err: any) {
log(ERROR, "failed to check for missed chain transactions:", err.message || err) log(ERROR, "failed to check for missed chain transactions:", err.message || err)
} }
} }
reprocessStuckPendingTx = async (log: PubLogger, currentHeight: number) => {
const { incoming } = await this.storage.paymentStorage.GetPendingTransactions()
const found = incoming.find(t => t.broadcast_height < currentHeight - 100)
if (found) {
log("found a possibly stuck pending transaction, reprocessing with full transaction history")
// There is a pending transaction more than 100 blocks old, this is likely a transaction
// that has a broadcast height higher than it actually is, so its not getting picked up when being processed
// by calling new block cb with height of 1, we make sure that even if the transaction has a newer height, it will still be processed
await this.newBlockCb(1, true)
}
}
private async getLatestTransactions(log: PubLogger): Promise<{ txs: Transaction[], currentHeight: number, lndPubkey: string, startHeight: number }> { private async getLatestTransactions(log: PubLogger): Promise<{ txs: Transaction[], currentHeight: number, lndPubkey: string, startHeight: number }> {
const lndInfo = await this.lnd.GetInfo() const lndInfo = await this.lnd.GetInfo()
const lndPubkey = lndInfo.identityPubkey const lndPubkey = lndInfo.identityPubkey
@ -273,14 +260,14 @@ export default class {
private async processRootAddressOutput(output: OutputDetail, tx: Transaction, addresses: LndAddress[], log: PubLogger): Promise<boolean> { private async processRootAddressOutput(output: OutputDetail, tx: Transaction, addresses: LndAddress[], log: PubLogger): Promise<boolean> {
const addr = addresses.find(a => a.address === output.address) const addr = addresses.find(a => a.address === output.address)
if (!addr) { if (!addr) {
throw new Error(`address ${output.address} not found in list of addresses`) throw new Error(`root address ${output.address} not found in list of addresses`)
} }
if (addr.change) { if (addr.change) {
log(`ignoring change address ${output.address}`) log(`ignoring change address ${output.address}`)
return false return false
} }
const outputIndex = Number(output.outputIndex) const outputIndex = Number(output.outputIndex)
const existingRootOp = await this.metrics.GetRootAddressTransaction(output.address, tx.txHash, outputIndex) const existingRootOp = await this.storage.metricsStorage.GetRootAddressTransaction(output.address, tx.txHash, outputIndex)
if (existingRootOp) { if (existingRootOp) {
return false return false
} }
@ -302,8 +289,11 @@ export default class {
const amount = Number(output.amount) const amount = Number(output.amount)
const outputIndex = Number(output.outputIndex) const outputIndex = Number(output.outputIndex)
log(`processing missed chain tx: address=${output.address}, txHash=${tx.txHash}, amount=${amount}, outputIndex=${outputIndex}`) log(`processing missed chain tx: address=${output.address}, txHash=${tx.txHash}, amount=${amount}, outputIndex=${outputIndex}`)
this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd', startHeight) try {
.catch(err => log(ERROR, "failed to process user address output:", err.message || err)) await this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd', startHeight)
} catch (err: any) {
log(ERROR, "failed to process user address output:", err.message || err)
}
return true return true
} }
@ -605,9 +595,15 @@ export default class {
async PayInternalAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> { async PayInternalAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> {
this.log("paying internal address") this.log("paying internal address")
let amount = req.amountSats
if (req.swap_operation_id) { if (req.swap_operation_id) {
const swap = await this.storage.paymentStorage.GetTransactionSwap(req.swap_operation_id, ctx.app_user_id)
amount = amount > 0 ? amount : swap?.invoice_amount || 0
await this.storage.paymentStorage.DeleteTransactionSwap(req.swap_operation_id) await this.storage.paymentStorage.DeleteTransactionSwap(req.swap_operation_id)
} }
if (amount <= 0) {
throw new Error("invalid tx amount")
}
const { blockHeight } = await this.lnd.GetInfo() const { blockHeight } = await this.lnd.GetInfo()
const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id)
const isManagedUser = ctx.user_id !== app.owner.user_id const isManagedUser = ctx.user_id !== app.owner.user_id
@ -634,11 +630,13 @@ export default class {
} }
} }
async ListSwaps(ctx: Types.UserContext): Promise<Types.SwapsList> { async ListTxSwaps(ctx: Types.UserContext): Promise<Types.TxSwapsList> {
const payments = await this.storage.paymentStorage.ListSwapPayments(ctx.app_user_id) console.log("listing tx swaps", { appUserId: ctx.app_user_id })
const payments = await this.storage.paymentStorage.ListTxSwapPayments(ctx.app_user_id)
console.log("payments", payments.length)
const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id)
const isManagedUser = ctx.user_id !== app.owner.user_id const isManagedUser = ctx.user_id !== app.owner.user_id
return this.swaps.ListSwaps(ctx.app_user_id, payments, p => { return this.swaps.ListTxSwaps(ctx.app_user_id, payments, p => {
const opId = `${Types.UserOperationType.OUTGOING_TX}-${p.serial_id}` const opId = `${Types.UserOperationType.OUTGOING_TX}-${p.serial_id}`
return this.newInvoicePaymentOperation({ amount: p.paid_amount, confirmed: p.paid_at_unix !== 0, invoice: p.invoice, opId, networkFee: p.routing_fees, serviceFee: p.service_fees, paidAtUnix: p.paid_at_unix }) return this.newInvoicePaymentOperation({ amount: p.paid_amount, confirmed: p.paid_at_unix !== 0, invoice: p.invoice, opId, networkFee: p.routing_fees, serviceFee: p.service_fees, paidAtUnix: p.paid_at_unix })
}, amt => this.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, amt, isManagedUser)) }, amt => this.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, amt, isManagedUser))
@ -973,29 +971,38 @@ export default class {
return { amount: payment.paid_amount, fees: payment.service_fees } return { amount: payment.paid_amount, fees: payment.service_fees }
} }
async CheckNewlyConfirmedTxs(height: number) { private async getTxConfs(txHash: string): Promise<number> {
const pending = await this.storage.paymentStorage.GetPendingTransactions() try {
let lowestHeight = height const info = await this.lnd.GetTx(txHash)
const map: Record<string, PendingTx> = {} const { numConfirmations: confs, amount: amt } = info
const checkTx = (t: PendingTx) => {
if (t.tx.broadcast_height < lowestHeight) { lowestHeight = t.tx.broadcast_height }
map[t.tx.tx_hash] = t
}
pending.incoming.forEach(t => checkTx({ type: "incoming", tx: t }))
pending.outgoing.forEach(t => checkTx({ type: "outgoing", tx: t }))
const { transactions } = await this.lnd.GetTransactions(lowestHeight)
const newlyConfirmedTxs = transactions.map(tx => {
const { txHash, numConfirmations: confs, amount: amt } = tx
const t = map[txHash]
if (!t || confs === 0) {
return
}
if (confs > 2 || (amt <= confInTwo && confs > 1) || (amt <= confInOne && confs > 0)) { if (confs > 2 || (amt <= confInTwo && confs > 1) || (amt <= confInOne && confs > 0)) {
return { ...t, confs } return confs
} }
}) } catch (err: any) {
return newlyConfirmedTxs.filter(t => t !== undefined) as (PendingTx & { confs: number })[] getLogger({})("failed to get tx info", err.message || err)
}
return 0
}
async CheckNewlyConfirmedTxs() {
const pending = await this.storage.paymentStorage.GetPendingTransactions()
let log = getLogger({})
log("CheckNewlyConfirmedTxs ", pending.incoming.length, "incoming", pending.outgoing.length, "outgoing")
const confirmedIncoming: (PendingTx & { confs: number })[] = []
const confirmedOutgoing: (PendingTx & { confs: number })[] = []
for (const tx of pending.incoming) {
const confs = await this.getTxConfs(tx.tx_hash)
if (confs > 0) {
confirmedIncoming.push({ type: "incoming", tx: tx, confs })
}
}
for (const tx of pending.outgoing) {
const confs = await this.getTxConfs(tx.tx_hash)
if (confs > 0) {
confirmedOutgoing.push({ type: "outgoing", tx: tx, confs })
}
}
return confirmedIncoming.concat(confirmedOutgoing)
} }
async CleanupOldUnpaidInvoices() { async CleanupOldUnpaidInvoices() {

View file

@ -226,7 +226,7 @@ export default class SanityChecker {
async VerifyEventsLog() { async VerifyEventsLog() {
this.events = await this.storage.eventsLog.GetAllLogs() this.events = await this.storage.eventsLog.GetAllLogs()
this.invoices = (await this.lnd.GetAllPaidInvoices(1000)).invoices this.invoices = (await this.lnd.GetAllInvoices(1000)).invoices
this.payments = (await this.lnd.GetAllPayments(1000)).payments this.payments = (await this.lnd.GetAllPayments(1000)).payments
this.incrementSources = {} this.incrementSources = {}

View file

@ -29,6 +29,7 @@ export class Watchdog {
ready = false ready = false
interval: NodeJS.Timer; interval: NodeJS.Timer;
lndPubKey: string; lndPubKey: string;
lastHandlerRootOpsAtUnix = 0
constructor(settings: SettingsManager, liquidityManager: LiquidityManager, lnd: LND, storage: Storage, utils: Utils, rugPullTracker: RugPullTracker) { constructor(settings: SettingsManager, liquidityManager: LiquidityManager, lnd: LND, storage: Storage, utils: Utils, rugPullTracker: RugPullTracker) {
this.lnd = lnd; this.lnd = lnd;
this.settings = settings; this.settings = settings;
@ -67,7 +68,7 @@ export class Watchdog {
await this.getTracker() await this.getTracker()
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance) this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance)
const { totalExternal, otherExternal } = await this.getAggregatedExternalBalance() const { totalExternal } = await this.getAggregatedExternalBalance()
this.initialLndBalance = totalExternal this.initialLndBalance = totalExternal
this.initialUsersBalance = totalUsersBalance this.initialUsersBalance = totalUsersBalance
const fwEvents = await this.lnd.GetForwardingHistory(0, this.startedAtUnix) const fwEvents = await this.lnd.GetForwardingHistory(0, this.startedAtUnix)
@ -76,8 +77,6 @@ export class Watchdog {
const paymentFound = await this.storage.paymentStorage.GetMaxPaymentIndex() const paymentFound = await this.storage.paymentStorage.GetMaxPaymentIndex()
const knownMaxIndex = paymentFound.length > 0 ? Math.max(paymentFound[0].paymentIndex, 0) : 0 const knownMaxIndex = paymentFound.length > 0 ? Math.max(paymentFound[0].paymentIndex, 0) : 0
this.latestPaymentIndexOffset = await this.lnd.GetLatestPaymentIndex(knownMaxIndex) this.latestPaymentIndexOffset = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, oext: otherExternal }
//getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd: 0, deltaUsers: 0, totalExternal, latestIndex: this.latestPaymentIndexOffset, other }))
this.interval = setInterval(() => { this.interval = setInterval(() => {
if (this.latestCheckStart + (1000 * 58) < Date.now()) { if (this.latestCheckStart + (1000 * 58) < Date.now()) {
this.PaymentRequested() this.PaymentRequested()
@ -93,7 +92,49 @@ export class Watchdog {
fwEvents.forwardingEvents.forEach((event) => { fwEvents.forwardingEvents.forEach((event) => {
this.accumulatedHtlcFees += Number(event.fee) this.accumulatedHtlcFees += Number(event.fee)
}) })
}
handleRootOperations = async () => {
let pendingChange = 0
const pendingChainPayments = await this.storage.metricsStorage.GetPendingChainPayments()
for (const payment of pendingChainPayments) {
try {
const tx = await this.lnd.GetTx(payment.operation_identifier)
if (tx.numConfirmations > 0) {
await this.storage.metricsStorage.SetRootOpConfirmed(payment.serial_id)
continue
}
tx.outputDetails.forEach(o => pendingChange += o.isOurAddress ? Number(o.amount) : 0)
} catch (err: any) {
this.log("Error getting tx for root operation", err.message || err)
}
}
let newReceived = 0
let newSpent = 0
if (this.lastHandlerRootOpsAtUnix === 0) {
this.lastHandlerRootOpsAtUnix = Math.floor(Date.now() / 1000)
return { newReceived, newSpent, pendingChange }
}
const newOps = await this.storage.metricsStorage.GetRootOperations({ from: this.lastHandlerRootOpsAtUnix })
newOps.forEach(o => {
switch (o.operation_type) {
case 'chain_payment':
newSpent += Number(o.operation_amount)
break
case 'invoice_payment':
newSpent += Number(o.operation_amount)
break
case 'chain':
newReceived += Number(o.operation_amount)
break
case 'invoice':
newReceived += Number(o.operation_amount)
break
}
})
return { newReceived, newSpent, pendingChange }
} }
getAggregatedExternalBalance = async () => { getAggregatedExternalBalance = async () => {
@ -101,8 +142,9 @@ export class Watchdog {
const feesPaidForLiquidity = this.liquidityManager.GetPaidFees() const feesPaidForLiquidity = this.liquidityManager.GetPaidFees()
const pb = await this.rugPullTracker.CheckProviderBalance() const pb = await this.rugPullTracker.CheckProviderBalance()
const providerBalance = pb.prevBalance || pb.balance const providerBalance = pb.prevBalance || pb.balance
const otherExternal = { pb: providerBalance, f: feesPaidForLiquidity, lnd: totalLndBalance, olnd: othersFromLnd } const { newReceived, newSpent, pendingChange } = await this.handleRootOperations()
return { totalExternal: totalLndBalance + providerBalance + feesPaidForLiquidity, otherExternal } const opsTotal = newReceived + pendingChange - newSpent
return { totalExternal: totalLndBalance + providerBalance + feesPaidForLiquidity + opsTotal }
} }
checkBalanceUpdate = async (deltaLnd: number, deltaUsers: number) => { checkBalanceUpdate = async (deltaLnd: number, deltaUsers: number) => {
@ -187,7 +229,7 @@ export class Watchdog {
} }
const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance()
this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance) this.utils.stateBundler.AddBalancePoint('usersBalance', totalUsersBalance)
const { totalExternal, otherExternal } = await this.getAggregatedExternalBalance() const { totalExternal } = await this.getAggregatedExternalBalance()
this.utils.stateBundler.AddBalancePoint('accumulatedHtlcFees', this.accumulatedHtlcFees) this.utils.stateBundler.AddBalancePoint('accumulatedHtlcFees', this.accumulatedHtlcFees)
const deltaLnd = totalExternal - (this.initialLndBalance + this.accumulatedHtlcFees) const deltaLnd = totalExternal - (this.initialLndBalance + this.accumulatedHtlcFees)
const deltaUsers = totalUsersBalance - this.initialUsersBalance const deltaUsers = totalUsersBalance - this.initialUsersBalance
@ -196,15 +238,17 @@ export class Watchdog {
const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset) const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset)
const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex) const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
const historyMismatch = newLatest > knownMaxIndex const historyMismatch = newLatest > knownMaxIndex
const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, km: knownMaxIndex, nl: newLatest, oext: otherExternal }
//getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd, deltaUsers, totalExternal, other }))
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (historyMismatch) { if (historyMismatch) {
getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations") this.log("Payment index advanced from", knownMaxIndex, "to", newLatest, "- updating offset (likely LND restart or external payment)")
this.lnd.LockOutgoingOperations() this.latestPaymentIndexOffset = newLatest
return
} }
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (deny) { if (deny) {
if (historyMismatch) {
getLogger({ component: 'bark' })("Balance mismatch with unexpected payment history, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
this.log("Balance mismatch detected in absolute update, but history is ok") this.log("Balance mismatch detected in absolute update, but history is ok")
} }
this.lnd.UnlockOutgoingOperations() this.lnd.UnlockOutgoingOperations()

View file

@ -414,9 +414,7 @@ export default class Handler {
await this.storage.metricsStorage.AddRootOperation("chain", `${address}:${txOutput.hash}:${txOutput.index}`, amount) await this.storage.metricsStorage.AddRootOperation("chain", `${address}:${txOutput.hash}:${txOutput.index}`, amount)
} }
async GetRootAddressTransaction(address: string, txHash: string, index: number) {
return this.storage.metricsStorage.GetRootOperation("chain", `${address}:${txHash}:${index}`)
}
async AddRootInvoicePaid(paymentRequest: string, amount: number) { async AddRootInvoicePaid(paymentRequest: string, amount: number) {
await this.storage.metricsStorage.AddRootOperation("invoice", paymentRequest, amount) await this.storage.metricsStorage.AddRootOperation("invoice", paymentRequest, amount)
@ -425,8 +423,13 @@ export default class Handler {
const mapRootOpType = (opType: string): Types.OperationType => { const mapRootOpType = (opType: string): Types.OperationType => {
switch (opType) { switch (opType) {
case "chain": return Types.OperationType.CHAIN_OP case "chain_payment":
case "invoice": return Types.OperationType.INVOICE_OP case "chain":
return Types.OperationType.CHAIN_OP
case "invoice_payment":
case "invoice":
return Types.OperationType.INVOICE_OP
default: throw new Error("Unknown operation type") default: throw new Error("Unknown operation type")
} }
} }

View file

@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => {
send(event) send(event)
}) })
} */ } */
const sendToNostr: NostrSend = (initiator, data, relays) => { const sendToNostr: NostrSend = async (initiator, data, relays) => {
if (!subProcessHandler) { if (!subProcessHandler) {
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized") getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
return return
} }
subProcessHandler.Send(initiator, data, relays) await subProcessHandler.Send(initiator, data, relays)
} }
send({ type: 'ready' }) send({ type: 'ready' })

View file

@ -11,28 +11,69 @@ export default class NostrSubprocess {
utils: Utils utils: Utils
awaitingPongs: (() => void)[] = [] awaitingPongs: (() => void)[] = []
log = getLogger({}) log = getLogger({})
latestRestart = 0
private settings: NostrSettings
private eventCallback: EventCallback
private beaconCallback: BeaconCallback
private isShuttingDown = false
constructor(settings: NostrSettings, utils: Utils, eventCallback: EventCallback, beaconCallback: BeaconCallback) { constructor(settings: NostrSettings, utils: Utils, eventCallback: EventCallback, beaconCallback: BeaconCallback) {
this.utils = utils this.utils = utils
this.settings = settings
this.eventCallback = eventCallback
this.beaconCallback = beaconCallback
this.startSubProcess()
}
private cleanupProcess() {
if (this.childProcess) {
this.childProcess.removeAllListeners()
if (!this.childProcess.killed) {
this.childProcess.kill('SIGTERM')
}
}
}
private startSubProcess() {
this.cleanupProcess()
this.childProcess = fork("./build/src/services/nostr/handler") this.childProcess = fork("./build/src/services/nostr/handler")
this.childProcess.on("error", (error) => { this.childProcess.on("error", (error) => {
this.log(ERROR, "nostr subprocess error", error) this.log(ERROR, "nostr subprocess error", error)
}) })
this.childProcess.on("exit", (code) => { this.childProcess.on("exit", (code, signal) => {
this.log(ERROR, `nostr subprocess exited with code ${code}`) if (this.isShuttingDown) {
if (!code) { this.log("nostr subprocess stopped")
return return
} }
throw new Error(`nostr subprocess exited with code ${code}`)
if (code === 0) {
this.log("nostr subprocess exited cleanly")
return
}
this.log(ERROR, `nostr subprocess exited with code ${code} and signal ${signal}`)
const now = Date.now()
if (now - this.latestRestart < 5000) {
this.log(ERROR, "nostr subprocess exited too quickly, not restarting")
throw new Error("nostr subprocess crashed repeatedly")
}
this.log("restarting nostr subprocess...")
this.latestRestart = now
setTimeout(() => this.startSubProcess(), 100)
}) })
this.childProcess.on("message", (message: ChildProcessResponse) => { this.childProcess.on("message", (message: ChildProcessResponse) => {
switch (message.type) { switch (message.type) {
case 'ready': case 'ready':
this.sendToChildProcess({ type: 'settings', settings: settings }) this.sendToChildProcess({ type: 'settings', settings: this.settings })
break; break;
case 'event': case 'event':
eventCallback(message.event) this.eventCallback(message.event)
break break
case 'processMetrics': case 'processMetrics':
this.utils.tlvStorageFactory.ProcessMetrics(message.metrics, 'nostr') this.utils.tlvStorageFactory.ProcessMetrics(message.metrics, 'nostr')
@ -42,7 +83,7 @@ export default class NostrSubprocess {
this.awaitingPongs = [] this.awaitingPongs = []
break break
case 'beacon': case 'beacon':
beaconCallback({ content: message.content, pub: message.pub }) this.beaconCallback({ content: message.content, pub: message.pub })
break break
default: default:
console.error("unknown nostr event response", message) console.error("unknown nostr event response", message)
@ -50,11 +91,15 @@ export default class NostrSubprocess {
} }
}) })
} }
sendToChildProcess(message: ChildProcessRequest) { sendToChildProcess(message: ChildProcessRequest) {
this.childProcess.send(message) if (this.childProcess && !this.childProcess.killed) {
this.childProcess.send(message)
}
} }
Reset(settings: NostrSettings) { Reset(settings: NostrSettings) {
this.settings = settings
this.sendToChildProcess({ type: 'settings', settings }) this.sendToChildProcess({ type: 'settings', settings })
} }
@ -68,7 +113,9 @@ export default class NostrSubprocess {
Send(initiator: SendInitiator, data: SendData, relays?: string[]) { Send(initiator: SendInitiator, data: SendData, relays?: string[]) {
this.sendToChildProcess({ type: 'send', data, initiator, relays }) this.sendToChildProcess({ type: 'send', data, initiator, relays })
} }
Stop() { Stop() {
this.childProcess.kill() this.isShuttingDown = true
this.cleanupProcess()
} }
} }

View file

@ -1,14 +1,14 @@
import { base64 } from "@scure/base"; import { base64, hex } from "@scure/base";
import { randomBytes } from "@noble/hashes/utils"; import { randomBytes } from "@noble/hashes/utils";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20"; import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
import { secp256k1 } from "@noble/curves/secp256k1"; import { secp256k1 } from "@noble/curves/secp256k1.js";
import { sha256 } from "@noble/hashes/sha256"; import { sha256 } from "@noble/hashes/sha256";
export type EncryptedData = { export type EncryptedData = {
ciphertext: Uint8Array; ciphertext: Uint8Array;
nonce: Uint8Array; nonce: Uint8Array;
} }
export const getSharedSecret = (privateKey: string, publicKey: string) => { export const getSharedSecret = (privateKey: string, publicKey: string) => {
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey); const key = secp256k1.getSharedSecret(hex.decode(privateKey), hex.decode("02" + publicKey));
return sha256(key.slice(1, 33)); return sha256(key.slice(1, 33));
} }

View file

@ -16,7 +16,7 @@ export type SendDataContent = { type: "content", content: string, pub: string }
export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
export type SendData = SendDataContent | SendDataEvent export type SendData = SendDataContent | SendDataEvent
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => Promise<void>
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string } export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo } export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
@ -203,21 +203,26 @@ export class NostrPool {
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex')) const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
let sent = false let sent = false
const log = getLogger({ appName: keys.name }) const log = getLogger({ appName: keys.name })
// const r = relays ? relays : this.getServiceRelays() this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
const pool = new SimplePool() const pool = new SimplePool()
await Promise.all(pool.publish(relays, signed).map(async p => { try {
try { await Promise.all(pool.publish(relays, signed).map(async p => {
await p try {
sent = true await p
} catch (e: any) { sent = true
console.log(e) } catch (e: any) {
log(e) this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
log(e)
}
}))
if (!sent) {
this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`)
log("failed to send event")
} else {
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
} }
})) } finally {
if (!sent) { pool.close(relays)
log("failed to send event")
} else {
//log("sent event")
} }
} }

View file

@ -1,7 +1,7 @@
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js" import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
import { getLogger } from "../helpers/logger.js" import { ERROR, getLogger } from "../helpers/logger.js"
export class NostrSender { export class NostrSender {
private _nostrSend: NostrSend = () => { throw new Error('nostr send not initialized yet') } private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') }
private isReady: boolean = false private isReady: boolean = false
private onReadyCallbacks: (() => void)[] = [] private onReadyCallbacks: (() => void)[] = []
private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = [] private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = []
@ -12,7 +12,12 @@ export class NostrSender {
this.isReady = true this.isReady = true
this.onReadyCallbacks.forEach(cb => cb()) this.onReadyCallbacks.forEach(cb => cb())
this.onReadyCallbacks = [] this.onReadyCallbacks = []
this.pendingSends.forEach(send => this._nostrSend(send.initiator, send.data, send.relays)) // Process pending sends with proper error handling
this.pendingSends.forEach(send => {
this._nostrSend(send.initiator, send.data, send.relays).catch(e => {
this.log(ERROR, "failed to send pending event", e.message || e)
})
})
this.pendingSends = [] this.pendingSends = []
} }
OnReady(callback: () => void) { OnReady(callback: () => void) {
@ -22,13 +27,16 @@ export class NostrSender {
this.onReadyCallbacks.push(callback) this.onReadyCallbacks.push(callback)
} }
} }
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) { Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void {
if (!this.isReady) { if (!this.isReady) {
this.log("tried to send before nostr was ready, caching request") this.log("tried to send before nostr was ready, caching request")
this.pendingSends.push({ initiator, data, relays }) this.pendingSends.push({ initiator, data, relays })
return return
} }
this._nostrSend(initiator, data, relays) // Fire and forget but log errors
this._nostrSend(initiator, data, relays).catch(e => {
this.log(ERROR, "failed to send event", e.message || e)
})
} }
IsReady() { IsReady() {
return this.isReady return this.isReady

View file

@ -91,6 +91,14 @@ export default (mainHandler: Main): Types.ServerMethods => {
if (err != null) throw new Error(err.message) if (err != null) throw new Error(err.message)
return mainHandler.adminManager.CloseChannel(req) return mainHandler.adminManager.CloseChannel(req)
}, },
BumpTx: async ({ ctx, req }) => {
const err = Types.BumpTxValidate(req, {
txid_CustomCheck: txid => txid !== '',
sat_per_vbyte_CustomCheck: spv => spv > 0
})
if (err != null) throw new Error(err.message)
return mainHandler.adminManager.BumpTx(req)
},
GetAdminTransactionSwapQuotes: async ({ ctx, req }) => { GetAdminTransactionSwapQuotes: async ({ ctx, req }) => {
const err = Types.TransactionSwapRequestValidate(req, { const err = Types.TransactionSwapRequestValidate(req, {
transaction_amount_sats_CustomCheck: amt => amt > 0 transaction_amount_sats_CustomCheck: amt => amt > 0
@ -106,6 +114,36 @@ export default (mainHandler: Main): Types.ServerMethods => {
if (err != null) throw new Error(err.message) if (err != null) throw new Error(err.message)
return mainHandler.adminManager.PayAdminTransactionSwap(req) return mainHandler.adminManager.PayAdminTransactionSwap(req)
}, },
ListAdminTxSwaps: async ({ ctx }) => {
return mainHandler.adminManager.ListAdminTxSwaps()
},
GetAdminInvoiceSwapQuotes: async ({ ctx, req }) => {
const err = Types.InvoiceSwapRequestValidate(req, {
amount_sats_CustomCheck: amt => amt > 0
})
if (err != null) throw new Error(err.message)
return mainHandler.adminManager.GetAdminInvoiceSwapQuotes(req)
},
RefundAdminInvoiceSwap: async ({ ctx, req }) => {
const err = Types.RefundAdminInvoiceSwapRequestValidate(req, {
swap_operation_id_CustomCheck: id => id !== '',
})
if (err != null) throw new Error(err.message)
return mainHandler.adminManager.RefundAdminInvoiceSwap(req)
},
ListAdminInvoiceSwaps: async ({ ctx }) => {
return mainHandler.adminManager.ListAdminInvoiceSwaps()
},
PayAdminInvoiceSwap: async ({ ctx, req }) => {
const err = Types.PayAdminInvoiceSwapRequestValidate(req, {
swap_operation_id_CustomCheck: id => id !== '',
})
if (err != null) throw new Error(err.message)
return mainHandler.adminManager.PayAdminInvoiceSwap(req)
},
GetAssetsAndLiabilities: async ({ ctx, req }) => {
return mainHandler.adminManager.GetAssetsAndLiabilities(req)
},
GetProvidersDisruption: async () => { GetProvidersDisruption: async () => {
return mainHandler.metricsManager.GetProvidersDisruption() return mainHandler.metricsManager.GetProvidersDisruption()
}, },
@ -145,9 +183,7 @@ export default (mainHandler: Main): Types.ServerMethods => {
GetUserOperations: async ({ ctx, req }) => { GetUserOperations: async ({ ctx, req }) => {
return mainHandler.paymentManager.GetUserOperations(ctx.user_id, req) return mainHandler.paymentManager.GetUserOperations(ctx.user_id, req)
}, },
ListAdminSwaps: async ({ ctx }) => {
return mainHandler.adminManager.ListAdminSwaps()
},
GetPaymentState: async ({ ctx, req }) => { GetPaymentState: async ({ ctx, req }) => {
const err = Types.GetPaymentStateRequestValidate(req, { const err = Types.GetPaymentStateRequestValidate(req, {
invoice_CustomCheck: invoice => invoice !== "" invoice_CustomCheck: invoice => invoice !== ""
@ -159,14 +195,14 @@ export default (mainHandler: Main): Types.ServerMethods => {
PayAddress: async ({ ctx, req }) => { PayAddress: async ({ ctx, req }) => {
const err = Types.PayAddressRequestValidate(req, { const err = Types.PayAddressRequestValidate(req, {
address_CustomCheck: addr => addr !== '', address_CustomCheck: addr => addr !== '',
amountSats_CustomCheck: amt => amt > 0, // amountSats_CustomCheck: amt => amt > 0,
// satsPerVByte_CustomCheck: spb => spb > 0 // satsPerVByte_CustomCheck: spb => spb > 0
}) })
if (err != null) throw new Error(err.message) if (err != null) throw new Error(err.message)
return mainHandler.paymentManager.PayAddress(ctx, req) return mainHandler.paymentManager.PayAddress(ctx, req)
}, },
ListSwaps: async ({ ctx }) => { ListTxSwaps: async ({ ctx }) => {
return mainHandler.paymentManager.ListSwaps(ctx) return mainHandler.paymentManager.ListTxSwaps(ctx)
}, },
GetTransactionSwapQuotes: async ({ ctx, req }) => { GetTransactionSwapQuotes: async ({ ctx, req }) => {
return mainHandler.paymentManager.GetTransactionSwapQuotes(ctx, req) return mainHandler.paymentManager.GetTransactionSwapQuotes(ctx, req)

View file

@ -1,5 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { Between, FindOperator, IsNull, LessThanOrEqual, MoreThanOrEqual, In } from "typeorm" import { Between, FindOperator, IsNull, LessThanOrEqual, MoreThanOrEqual } from "typeorm"
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { Application } from "./entity/Application.js" import { Application } from "./entity/Application.js"
import UserStorage from './userStorage.js'; import UserStorage from './userStorage.js';
@ -72,7 +72,8 @@ export default class {
user: user, user: user,
application, application,
identifier: userIdentifier, identifier: userIdentifier,
nostr_public_key: nostrPub nostr_public_key: nostrPub,
topic_id: crypto.randomBytes(32).toString('hex')
}, txId) }, txId)
}) })
} }
@ -160,10 +161,16 @@ export default class {
this.dbs.Remove<User>('User', baseUser, txId) this.dbs.Remove<User>('User', baseUser, txId)
} }
async RemoveAppUsersAndBaseUsers(appUserIds: string[],baseUser:string, txId?: string) { async RemoveAppUsersAndBaseUsers(appUserIds: string[], baseUser: string, txId?: string) {
await this.dbs.Delete<ApplicationUser>('ApplicationUser', { identifier: In(appUserIds) }, txId) for (const appUserId of appUserIds) {
await this.dbs.Delete<User>('User', { user_id: baseUser }, txId) const appUser = await this.dbs.FindOne<ApplicationUser>('ApplicationUser', { where: { identifier: appUserId } }, txId)
if (appUser) {
await this.dbs.Delete<ApplicationUser>('ApplicationUser', appUser.serial_id, txId)
}
}
const user = await this.userStorage.FindUser(baseUser, txId)
if (!user) return
await this.dbs.Delete<User>('User', user.serial_id, txId)
} }

View file

@ -30,6 +30,7 @@ import * as fs from 'fs'
import { UserAccess } from "../entity/UserAccess.js" import { UserAccess } from "../entity/UserAccess.js"
import { AdminSettings } from "../entity/AdminSettings.js" import { AdminSettings } from "../entity/AdminSettings.js"
import { TransactionSwap } from "../entity/TransactionSwap.js" import { TransactionSwap } from "../entity/TransactionSwap.js"
import { InvoiceSwap } from "../entity/InvoiceSwap.js"
export type DbSettings = { export type DbSettings = {
@ -76,7 +77,8 @@ export const MainDbEntities = {
'AppUserDevice': AppUserDevice, 'AppUserDevice': AppUserDevice,
'UserAccess': UserAccess, 'UserAccess': UserAccess,
'AdminSettings': AdminSettings, 'AdminSettings': AdminSettings,
'TransactionSwap': TransactionSwap 'TransactionSwap': TransactionSwap,
'InvoiceSwap': InvoiceSwap
} }
export type MainDbNames = keyof typeof MainDbEntities export type MainDbNames = keyof typeof MainDbEntities
export const MainDbEntitiesNames = Object.keys(MainDbEntities) export const MainDbEntitiesNames = Object.keys(MainDbEntities)

View file

@ -8,10 +8,19 @@ type SerializedFindOperator = {
} }
export function serializeFindOperator(operator: FindOperator<any>): SerializedFindOperator { export function serializeFindOperator(operator: FindOperator<any>): SerializedFindOperator {
let value: any;
if (Array.isArray(operator['value']) && operator['type'] !== 'between') {
value = operator['value'].map(serializeFindOperator);
} else if ((operator as any).child !== undefined) {
// Not(IsNull()) etc.: TypeORM's .value getter unwraps nested FindOperators, so we'd lose the inner operator. Use .child to serialize the nested operator.
value = serializeFindOperator((operator as any).child);
} else {
value = operator['value'];
}
return { return {
_type: 'FindOperator', _type: 'FindOperator',
type: operator['type'], type: operator['type'],
value: (Array.isArray(operator['value']) && operator['type'] !== 'between') ? operator["value"].map(serializeFindOperator) : operator["value"], value,
}; };
} }
@ -51,7 +60,8 @@ export function deserializeFindOperator(serialized: SerializedFindOperator): Fin
} }
} }
export function serializeRequest<T>(r: object): T { export function serializeRequest<T>(r: object, debug = false): T {
if (debug) console.log("serializeRequest", r)
if (!r || typeof r !== 'object') { if (!r || typeof r !== 'object') {
return r; return r;
} }
@ -61,23 +71,24 @@ export function serializeRequest<T>(r: object): T {
} }
if (Array.isArray(r)) { if (Array.isArray(r)) {
return r.map(item => serializeRequest(item)) as any; return r.map(item => serializeRequest(item, debug)) as any;
} }
const result: any = {}; const result: any = {};
for (const [key, value] of Object.entries(r)) { for (const [key, value] of Object.entries(r)) {
result[key] = serializeRequest(value); result[key] = serializeRequest(value, debug);
} }
return result; return result;
} }
export function deserializeRequest<T>(r: object): T { export function deserializeRequest<T>(r: object, debug = false): T {
if (debug) console.log("deserializeRequest", r)
if (!r || typeof r !== 'object') { if (!r || typeof r !== 'object') {
return r; return r;
} }
if (Array.isArray(r)) { if (Array.isArray(r)) {
return r.map(item => deserializeRequest(item)) as any; return r.map(item => deserializeRequest(item, debug)) as any;
} }
if (r && typeof r === 'object' && (r as any)._type === 'FindOperator') { if (r && typeof r === 'object' && (r as any)._type === 'FindOperator') {
@ -86,7 +97,7 @@ export function deserializeRequest<T>(r: object): T {
const result: any = {}; const result: any = {};
for (const [key, value] of Object.entries(r)) { for (const [key, value] of Object.entries(r)) {
result[key] = deserializeRequest(value); result[key] = deserializeRequest(value, debug);
} }
return result; return result;
} }

View file

@ -29,7 +29,7 @@ export class StorageInterface extends EventEmitter {
private debug: boolean = false; private debug: boolean = false;
private utils: Utils private utils: Utils
private dbType: 'main' | 'metrics' private dbType: 'main' | 'metrics'
private log = getLogger({component: 'StorageInterface'}) private log = getLogger({ component: 'StorageInterface' })
constructor(utils: Utils) { constructor(utils: Utils) {
super(); super();
this.initializeSubprocess(); this.initializeSubprocess();
@ -61,13 +61,13 @@ export class StorageInterface extends EventEmitter {
this.isConnected = false; this.isConnected = false;
}); });
this.process.on('exit', (code: number) => { this.process.on('exit', (code: number, signal: string) => {
this.log(ERROR, `Storage processor exited with code ${code}`); this.log(ERROR, `Storage processor exited with code ${code} and signal ${signal}`);
this.isConnected = false; this.isConnected = false;
if (!code) { if (code === 0) {
return return
} }
throw new Error(`Storage processor exited with code ${code}`) throw new Error(`Storage processor exited with code ${code} and signal ${signal}`)
}); });
this.isConnected = true; this.isConnected = true;
@ -104,9 +104,10 @@ export class StorageInterface extends EventEmitter {
return this.handleOp<T | null>(findOp) return this.handleOp<T | null>(findOp)
} }
Find<T>(entity: DBNames, q: QueryOptions<T>, txId?: string): Promise<T[]> { Find<T>(entity: DBNames, q: QueryOptions<T>, txId?: string, debug = false): Promise<T[]> {
if (debug) console.log("Find", { entity })
const opId = Math.random().toString() const opId = Math.random().toString()
const findOp: FindOperation<T> = { type: 'find', entity, opId, q, txId } const findOp: FindOperation<T> = { type: 'find', entity, opId, q, txId, debug }
return this.handleOp<T[]>(findOp) return this.handleOp<T[]>(findOp)
} }
@ -166,32 +167,33 @@ export class StorageInterface extends EventEmitter {
} }
private handleOp<T>(op: IStorageOperation): Promise<T> { private handleOp<T>(op: IStorageOperation): Promise<T> {
if (this.debug) console.log('handleOp', op) if (this.debug || op.debug) console.log('handleOp', op)
this.checkConnected() this.checkConnected()
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const responseHandler = (response: OperationResponse<T>) => { const responseHandler = (response: OperationResponse<T>) => {
if (this.debug) console.log('responseHandler', response) if (this.debug || op.debug) console.log('responseHandler', response)
if (!response.success) { if (!response.success) {
reject(new Error(response.error)); reject(new Error(response.error));
return return
} }
if (this.debug || op.debug) console.log("response", response, op)
if (response.type !== op.type) { if (response.type !== op.type) {
reject(new Error('Invalid storage response type')); reject(new Error('Invalid storage response type'));
return return
} }
resolve(deserializeResponseData(response.data)); resolve(deserializeResponseData(response.data));
} }
this.once(op.opId, responseHandler) this.once(op.opId, responseHandler)
this.process.send(this.serializeOperation(op)) this.process.send(this.serializeOperation(op))
}) })
} }
private serializeOperation(operation: IStorageOperation): IStorageOperation { private serializeOperation(operation: IStorageOperation, debug = false): IStorageOperation {
const serialized = { ...operation }; const serialized = { ...operation };
if ('q' in serialized) { if ('q' in serialized) {
(serialized as any).q = serializeRequest((serialized as any).q); (serialized as any).q = serializeRequest((serialized as any).q, debug);
} }
if (this.debug) { if (this.debug || debug) {
serialized.debug = true serialized.debug = true
} }
return serialized; return serialized;
@ -205,7 +207,7 @@ export class StorageInterface extends EventEmitter {
public disconnect() { public disconnect() {
if (this.process) { if (this.process) {
this.process.kill(); this.process.kill(0);
this.isConnected = false; this.isConnected = false;
this.debug = false; this.debug = false;
} }

View file

@ -26,6 +26,9 @@ export class ApplicationUser {
@Column({ default: "" }) @Column({ default: "" })
callback_url: string callback_url: string
@Column({ unique: true })
topic_id: string;
@CreateDateColumn() @CreateDateColumn()
created_at: Date created_at: Date

View file

@ -0,0 +1,94 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, UpdateDateColumn } from "typeorm";
import { User } from "./User";
@Entity()
export class InvoiceSwap {
@PrimaryGeneratedColumn('uuid')
swap_operation_id: string
@Column()
app_user_id: string
@Column()
swap_quote_id: string
@Column()
swap_tree: string
@Column()
claim_public_key: string
@Column()
payment_hash: string
/* @Column()
lockup_address: string */
/* @Column()
refund_public_key: string */
@Column()
timeout_block_height: number
@Column()
invoice: string
@Column()
invoice_amount: number
@Column()
transaction_amount: number
@Column()
swap_fee_sats: number
@Column()
chain_fee_sats: number
@Column()
ephemeral_public_key: string
@Column()
address: string
// the private key is used on to perform a swap, it does not hold any funds once the swap is completed
// the swap should only last a few seconds, so it is not a security risk to store the private key in the database
// the key is stored here mostly for recovery purposes, in case something goes wrong with the swap
@Column()
ephemeral_private_key: string
@Column({ default: false })
used: boolean
@Column({ default: 0 })
completed_at_unix: number
@Column({ default: 0 })
paid_at_unix: number
@Column({ default: "" })
preimage: string
@Column({ default: "" })
failure_reason: string
@Column({ default: "" })
tx_id: string
@Column({ default: "", type: "text" })
lockup_tx_hex: string
/* @Column({ default: "" })
address_paid: string */
@Column({ default: "" })
service_url: string
@CreateDateColumn()
created_at: Date
@UpdateDateColumn()
updated_at: Date
}

View file

@ -17,6 +17,9 @@ export class RootOperation {
@Column({ default: 0 }) @Column({ default: 0 })
at_unix: number at_unix: number
@Column({ default: false })
pending: boolean
@CreateDateColumn() @CreateDateColumn()
created_at: Date created_at: Date

View file

@ -60,6 +60,12 @@ export class TransactionSwap {
@Column({ default: "" }) @Column({ default: "" })
tx_id: string tx_id: string
@Column({ default: 0 })
completed_at_unix: number
@Column({ default: 0 })
paid_at_unix: number
@Column({ default: "" }) @Column({ default: "" })
address_paid: string address_paid: string

View file

@ -10,6 +10,7 @@ import { StorageInterface } from "./db/storageInterface.js";
import { Utils } from "../helpers/utilsWrapper.js"; import { Utils } from "../helpers/utilsWrapper.js";
import { Channel, ChannelEventUpdate } from "../../../proto/lnd/lightning.js"; import { Channel, ChannelEventUpdate } from "../../../proto/lnd/lightning.js";
import { ChannelEvent } from "./entity/ChannelEvent.js"; import { ChannelEvent } from "./entity/ChannelEvent.js";
export type RootOperationType = 'chain' | 'invoice' | 'chain_payment' | 'invoice_payment'
export default class { export default class {
//DB: DataSource | EntityManager //DB: DataSource | EntityManager
settings: StorageSettings settings: StorageSettings
@ -145,13 +146,27 @@ export default class {
} }
} }
async AddRootOperation(opType: string, id: string, amount: number, txId?: string) { async AddRootOperation(opType: RootOperationType, id: string, amount: number, pending = false, dbTxId?: string) {
return this.dbs.CreateAndSave<RootOperation>('RootOperation', { operation_type: opType, operation_amount: amount, operation_identifier: id, at_unix: Math.floor(Date.now() / 1000) }, txId) return this.dbs.CreateAndSave<RootOperation>('RootOperation', {
operation_type: opType, operation_amount: amount,
operation_identifier: id, at_unix: Math.floor(Date.now() / 1000), pending
}, dbTxId)
} }
async GetRootOperation(opType: string, id: string, txId?: string) { async GetRootOperation(opType: RootOperationType, id: string, txId?: string) {
return this.dbs.FindOne<RootOperation>('RootOperation', { where: { operation_type: opType, operation_identifier: id } }, txId) return this.dbs.FindOne<RootOperation>('RootOperation', { where: { operation_type: opType, operation_identifier: id } }, txId)
} }
async GetRootAddressTransaction(address: string, txHash: string, index: number) {
return this.GetRootOperation("chain", `${address}:${txHash}:${index}`)
}
async GetPendingChainPayments() {
return this.dbs.Find<RootOperation>('RootOperation', { where: { operation_type: 'chain_payment', pending: true } })
}
async SetRootOpConfirmed(serialId: number) {
return this.dbs.Update<RootOperation>('RootOperation', serialId, { pending: false })
}
async GetRootOperations({ from, to }: { from?: number, to?: number }, txId?: string) { async GetRootOperations({ from, to }: { from?: number, to?: number }, txId?: string) {
const q = getTimeQuery({ from, to }) const q = getTimeQuery({ from, to })

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InvoiceSwaps1769529793283 implements MigrationInterface {
name = 'InvoiceSwaps1769529793283'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "invoice_swap"`);
}
}

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InvoiceSwapsFixes1769805357459 implements MigrationInterface {
name = 'InvoiceSwapsFixes1769805357459'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''))`);
await queryRunner.query(`INSERT INTO "temporary_invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at" FROM "invoice_swap"`);
await queryRunner.query(`DROP TABLE "invoice_swap"`);
await queryRunner.query(`ALTER TABLE "temporary_invoice_swap" RENAME TO "invoice_swap"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "invoice_swap" RENAME TO "temporary_invoice_swap"`);
await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
await queryRunner.query(`INSERT INTO "invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at" FROM "temporary_invoice_swap"`);
await queryRunner.query(`DROP TABLE "temporary_invoice_swap"`);
}
}

View file

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ApplicationUserTopicId1770038768784 implements MigrationInterface {
name = 'ApplicationUserTopicId1770038768784'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_0a0dbb25a73306b037dec82251"`);
await queryRunner.query(`CREATE TABLE "temporary_application_user" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "identifier" varchar NOT NULL, "nostr_public_key" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "applicationSerialId" integer, "callback_url" varchar NOT NULL DEFAULT (''), "topic_id" varchar NOT NULL, CONSTRAINT "UQ_3175dc397c8285d1e532554dea5" UNIQUE ("nostr_public_key"), CONSTRAINT "REL_0796a381bcc624f52e9a155712" UNIQUE ("userSerialId"), CONSTRAINT "UQ_bd1a42f39fd7b4218bed5cc63d9" UNIQUE ("topic_id"), CONSTRAINT "FK_0796a381bcc624f52e9a155712b" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1b3bdb6f660cd99533a1e673ef1" FOREIGN KEY ("applicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_application_user"("serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url", "topic_id") SELECT "serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url", lower(hex(randomblob(32))) FROM "application_user"`);
await queryRunner.query(`DROP TABLE "application_user"`);
await queryRunner.query(`ALTER TABLE "temporary_application_user" RENAME TO "application_user"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0a0dbb25a73306b037dec82251" ON "application_user" ("identifier") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_0a0dbb25a73306b037dec82251"`);
await queryRunner.query(`ALTER TABLE "application_user" RENAME TO "temporary_application_user"`);
await queryRunner.query(`CREATE TABLE "application_user" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "identifier" varchar NOT NULL, "nostr_public_key" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "applicationSerialId" integer, "callback_url" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_3175dc397c8285d1e532554dea5" UNIQUE ("nostr_public_key"), CONSTRAINT "REL_0796a381bcc624f52e9a155712" UNIQUE ("userSerialId"), CONSTRAINT "FK_0796a381bcc624f52e9a155712b" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1b3bdb6f660cd99533a1e673ef1" FOREIGN KEY ("applicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "application_user"("serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url") SELECT "serial_id", "identifier", "nostr_public_key", "created_at", "updated_at", "userSerialId", "applicationSerialId", "callback_url" FROM "temporary_application_user"`);
await queryRunner.query(`DROP TABLE "temporary_application_user"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0a0dbb25a73306b037dec82251" ON "application_user" ("identifier") `);
}
}

View file

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class SwapTimestamps1771347307798 implements MigrationInterface {
name = 'SwapTimestamps1771347307798'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''), "completed_at_unix" integer NOT NULL DEFAULT (0), "paid_at_unix" integer NOT NULL DEFAULT (0))`);
await queryRunner.query(`INSERT INTO "temporary_invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex" FROM "invoice_swap"`);
await queryRunner.query(`DROP TABLE "invoice_swap"`);
await queryRunner.query(`ALTER TABLE "temporary_invoice_swap" RENAME TO "invoice_swap"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "invoice_swap" RENAME TO "temporary_invoice_swap"`);
await queryRunner.query(`CREATE TABLE "invoice_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "claim_public_key" varchar NOT NULL, "payment_hash" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "ephemeral_public_key" varchar NOT NULL, "address" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "preimage" varchar NOT NULL DEFAULT (''), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "lockup_tx_hex" text NOT NULL DEFAULT (''))`);
await queryRunner.query(`INSERT INTO "invoice_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "claim_public_key", "payment_hash", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "ephemeral_public_key", "address", "ephemeral_private_key", "used", "preimage", "failure_reason", "tx_id", "service_url", "created_at", "updated_at", "lockup_tx_hex" FROM "temporary_invoice_swap"`);
await queryRunner.query(`DROP TABLE "temporary_invoice_swap"`);
}
}

View file

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class RootOpPending1771524665409 implements MigrationInterface {
name = 'RootOpPending1771524665409'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_root_operation" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "operation_type" varchar NOT NULL, "operation_amount" integer NOT NULL, "operation_identifier" varchar NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "at_unix" integer NOT NULL DEFAULT (0), "pending" boolean NOT NULL DEFAULT (0))`);
await queryRunner.query(`INSERT INTO "temporary_root_operation"("serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix") SELECT "serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix" FROM "root_operation"`);
await queryRunner.query(`DROP TABLE "root_operation"`);
await queryRunner.query(`ALTER TABLE "temporary_root_operation" RENAME TO "root_operation"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "root_operation" RENAME TO "temporary_root_operation"`);
await queryRunner.query(`CREATE TABLE "root_operation" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "operation_type" varchar NOT NULL, "operation_amount" integer NOT NULL, "operation_identifier" varchar NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "at_unix" integer NOT NULL DEFAULT (0))`);
await queryRunner.query(`INSERT INTO "root_operation"("serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix") SELECT "serial_id", "operation_type", "operation_amount", "operation_identifier", "created_at", "updated_at", "at_unix" FROM "temporary_root_operation"`);
await queryRunner.query(`DROP TABLE "temporary_root_operation"`);
}
}

View file

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class TxSwapTimestamps1771878683383 implements MigrationInterface {
name = 'TxSwapTimestamps1771878683383'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_transaction_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "lockup_address" varchar NOT NULL, "refund_public_key" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "preimage" varchar NOT NULL, "ephemeral_public_key" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "address_paid" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''), "completed_at_unix" integer NOT NULL DEFAULT (0), "paid_at_unix" integer NOT NULL DEFAULT (0))`);
await queryRunner.query(`INSERT INTO "temporary_transaction_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url" FROM "transaction_swap"`);
await queryRunner.query(`DROP TABLE "transaction_swap"`);
await queryRunner.query(`ALTER TABLE "temporary_transaction_swap" RENAME TO "transaction_swap"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transaction_swap" RENAME TO "temporary_transaction_swap"`);
await queryRunner.query(`CREATE TABLE "transaction_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "lockup_address" varchar NOT NULL, "refund_public_key" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "preimage" varchar NOT NULL, "ephemeral_public_key" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "address_paid" varchar NOT NULL DEFAULT (''), "service_url" varchar NOT NULL DEFAULT (''))`);
await queryRunner.query(`INSERT INTO "transaction_swap"("swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url") SELECT "swap_operation_id", "app_user_id", "swap_quote_id", "swap_tree", "lockup_address", "refund_public_key", "timeout_block_height", "invoice", "invoice_amount", "transaction_amount", "swap_fee_sats", "chain_fee_sats", "preimage", "ephemeral_public_key", "ephemeral_private_key", "used", "failure_reason", "tx_id", "created_at", "updated_at", "address_paid", "service_url" FROM "temporary_transaction_swap"`);
await queryRunner.query(`DROP TABLE "temporary_transaction_swap"`);
}
}

View file

@ -1,28 +1,21 @@
import { Initial1703170309875 } from './1703170309875-initial.js' import { Initial1703170309875 } from './1703170309875-initial.js'
import { LndMetrics1703170330183 } from './1703170330183-lnd_metrics.js'
import { ChannelRouting1709316653538 } from './1709316653538-channel_routing.js'
import { LspOrder1718387847693 } from './1718387847693-lsp_order.js' import { LspOrder1718387847693 } from './1718387847693-lsp_order.js'
import { LiquidityProvider1719335699480 } from './1719335699480-liquidity_provider.js' import { LiquidityProvider1719335699480 } from './1719335699480-liquidity_provider.js'
import { LndNodeInfo1720187506189 } from './1720187506189-lnd_node_info.js' import { LndNodeInfo1720187506189 } from './1720187506189-lnd_node_info.js'
import { TrackedProvider1720814323679 } from './1720814323679-tracked_provider.js' import { TrackedProvider1720814323679 } from './1720814323679-tracked_provider.js'
import { CreateInviteTokenTable1721751414878 } from "./1721751414878-create_invite_token_table.js" import { CreateInviteTokenTable1721751414878 } from "./1721751414878-create_invite_token_table.js"
import { PaymentIndex1721760297610 } from './1721760297610-payment_index.js' import { PaymentIndex1721760297610 } from './1721760297610-payment_index.js'
import { HtlcCount1724266887195 } from './1724266887195-htlc_count.js'
import { BalanceEvents1724860966825 } from './1724860966825-balance_events.js'
import { DebitAccess1726496225078 } from './1726496225078-debit_access.js' import { DebitAccess1726496225078 } from './1726496225078-debit_access.js'
import { DebitAccessFixes1726685229264 } from './1726685229264-debit_access_fixes.js' import { DebitAccessFixes1726685229264 } from './1726685229264-debit_access_fixes.js'
import { DebitToPub1727105758354 } from './1727105758354-debit_to_pub.js' import { DebitToPub1727105758354 } from './1727105758354-debit_to_pub.js'
import { UserCbUrl1727112281043 } from './1727112281043-user_cb_url.js' import { UserCbUrl1727112281043 } from './1727112281043-user_cb_url.js'
import { RootOps1732566440447 } from './1732566440447-root_ops.js'
import { UserOffer1733502626042 } from './1733502626042-user_offer.js' import { UserOffer1733502626042 } from './1733502626042-user_offer.js'
import { RootOpsTime1745428134124 } from './1745428134124-root_ops_time.js'
import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js'
import { ManagementGrant1751307732346 } from './1751307732346-management_grant.js' import { ManagementGrant1751307732346 } from './1751307732346-management_grant.js'
import { ManagementGrantBanned1751989251513 } from './1751989251513-management_grant_banned.js' import { ManagementGrantBanned1751989251513 } from './1751989251513-management_grant_banned.js'
import { InvoiceCallbackUrls1752425992291 } from './1752425992291-invoice_callback_urls.js' import { InvoiceCallbackUrls1752425992291 } from './1752425992291-invoice_callback_urls.js'
import { AppUserDevice1753285173175 } from './1753285173175-app_user_device.js'
import { OldSomethingLeftover1753106599604 } from './1753106599604-old_something_leftover.js' import { OldSomethingLeftover1753106599604 } from './1753106599604-old_something_leftover.js'
import { UserReceivingInvoiceIdx1753109184611 } from './1753109184611-user_receiving_invoice_idx.js' import { UserReceivingInvoiceIdx1753109184611 } from './1753109184611-user_receiving_invoice_idx.js'
import { AppUserDevice1753285173175 } from './1753285173175-app_user_device.js'
import { UserAccess1759426050669 } from './1759426050669-user_access.js' import { UserAccess1759426050669 } from './1759426050669-user_access.js'
import { AddBlindToUserOffer1760000000000 } from './1760000000000-add_blind_to_user_offer.js' import { AddBlindToUserOffer1760000000000 } from './1760000000000-add_blind_to_user_offer.js'
import { ApplicationAvatarUrl1761000001000 } from './1761000001000-application_avatar_url.js' import { ApplicationAvatarUrl1761000001000 } from './1761000001000-application_avatar_url.js'
@ -32,6 +25,23 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js'
import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js'
import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js'
import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js'
import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js'
import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js'
import { ApplicationUserTopicId1770038768784 } from './1770038768784-application_user_topic_id.js'
import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js'
import { TxSwapTimestamps1771878683383 } from './1771878683383-tx_swap_timestamps.js'
import { LndMetrics1703170330183 } from './1703170330183-lnd_metrics.js'
import { ChannelRouting1709316653538 } from './1709316653538-channel_routing.js'
import { HtlcCount1724266887195 } from './1724266887195-htlc_count.js'
import { BalanceEvents1724860966825 } from './1724860966825-balance_events.js'
import { RootOps1732566440447 } from './1732566440447-root_ops.js'
import { RootOpsTime1745428134124 } from './1745428134124-root_ops_time.js'
import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js'
import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js'
export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189,
@ -39,9 +49,13 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui
DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513,
InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175,
UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098,
TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036] TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036,
InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798,
TxSwapTimestamps1771878683383]
export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411]
export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825,
RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411, RootOpPending1771524665409]
/* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise<boolean> => { /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise<boolean> => {
await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations) await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations)
return false return false

View file

@ -15,6 +15,7 @@ import TransactionsQueue from "./db/transactionsQueue.js";
import { LoggedEvent } from './eventsLog.js'; import { LoggedEvent } from './eventsLog.js';
import { StorageInterface } from './db/storageInterface.js'; import { StorageInterface } from './db/storageInterface.js';
import { TransactionSwap } from './entity/TransactionSwap.js'; import { TransactionSwap } from './entity/TransactionSwap.js';
import { InvoiceSwap } from './entity/InvoiceSwap.js';
export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record<string, string>, rejectUnauthorized?: boolean, token?: string, blind?: boolean, clinkRequesterPub?: string, clinkRequesterEventId?: string } export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record<string, string>, rejectUnauthorized?: boolean, token?: string, blind?: boolean, clinkRequesterPub?: string, clinkRequesterEventId?: string }
export const defaultInvoiceExpiry = 60 * 60 export const defaultInvoiceExpiry = 60 * 60
export default class { export default class {
@ -137,7 +138,15 @@ export default class {
} }
async RemoveUserInvoices(userId: string, txId?: string) { async RemoveUserInvoices(userId: string, txId?: string) {
return this.dbs.Delete<UserReceivingInvoice>('UserReceivingInvoice', { user: { user_id: userId } }, txId) const invoices = await this.dbs.Find<UserReceivingInvoice>('UserReceivingInvoice', { where: { user: { user_id: userId } } }, txId)
if (invoices.length === 0) {
return 0
}
let deleted = 0
for (const invoice of invoices) {
deleted += await this.dbs.Delete<UserReceivingInvoice>('UserReceivingInvoice', invoice.serial_id, txId)
}
return deleted
} }
async GetAddressOwner(address: string, txId?: string): Promise<UserReceivingAddress | null> { async GetAddressOwner(address: string, txId?: string): Promise<UserReceivingAddress | null> {
@ -151,6 +160,10 @@ export default class {
return this.dbs.FindOne<UserTransactionPayment>('UserTransactionPayment', { where: { address, tx_hash: txHash } }, txId) return this.dbs.FindOne<UserTransactionPayment>('UserTransactionPayment', { where: { address, tx_hash: txHash } }, txId)
} }
async GetTxHashPaymentOwner(txHash: string, txId?: string): Promise<UserTransactionPayment | null> {
return this.dbs.FindOne<UserTransactionPayment>('UserTransactionPayment', { where: { tx_hash: txHash } }, txId)
}
async GetInvoiceOwner(paymentRequest: string, txId?: string): Promise<UserReceivingInvoice | null> { async GetInvoiceOwner(paymentRequest: string, txId?: string): Promise<UserReceivingInvoice | null> {
return this.dbs.FindOne<UserReceivingInvoice>('UserReceivingInvoice', { where: { invoice: paymentRequest } }, txId) return this.dbs.FindOne<UserReceivingInvoice>('UserReceivingInvoice', { where: { invoice: paymentRequest } }, txId)
} }
@ -317,7 +330,51 @@ export default class {
} }
async RemoveUserEphemeralKeys(userId: string, txId?: string) { async RemoveUserEphemeralKeys(userId: string, txId?: string) {
return this.dbs.Delete<UserEphemeralKey>('UserEphemeralKey', { user: { user_id: userId } }, txId) const keys = await this.dbs.Find<UserEphemeralKey>('UserEphemeralKey', { where: { user: { user_id: userId } } }, txId)
if (keys.length === 0) {
return 0
}
let deleted = 0
for (const key of keys) {
deleted += await this.dbs.Delete<UserEphemeralKey>('UserEphemeralKey', key.serial_id, txId)
}
return deleted
}
async RemoveUserReceivingAddresses(userId: string, txId?: string) {
const addresses = await this.dbs.Find<UserReceivingAddress>('UserReceivingAddress', { where: { user: { user_id: userId } } }, txId)
for (const addr of addresses) {
const txs = await this.dbs.Find<AddressReceivingTransaction>('AddressReceivingTransaction', { where: { user_address: { serial_id: addr.serial_id } } }, txId)
for (const tx of txs) {
await this.dbs.Delete<AddressReceivingTransaction>('AddressReceivingTransaction', tx.serial_id, txId)
}
await this.dbs.Delete<UserReceivingAddress>('UserReceivingAddress', addr.serial_id, txId)
}
}
async RemoveUserInvoicePayments(userId: string, txId?: string) {
const payments = await this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { user: { user_id: userId } } }, txId)
for (const p of payments) {
await this.dbs.Delete<UserInvoicePayment>('UserInvoicePayment', p.serial_id, txId)
}
}
async RemoveUserTransactionPayments(userId: string, txId?: string) {
const payments = await this.dbs.Find<UserTransactionPayment>('UserTransactionPayment', { where: { user: { user_id: userId } } }, txId)
for (const p of payments) {
await this.dbs.Delete<UserTransactionPayment>('UserTransactionPayment', p.serial_id, txId)
}
}
async RemoveUserToUserPayments(userId: string, txId?: string) {
const asSender = await this.dbs.Find<UserToUserPayment>('UserToUserPayment', { where: { from_user: { user_id: userId } } }, txId)
const asReceiver = await this.dbs.Find<UserToUserPayment>('UserToUserPayment', { where: { to_user: { user_id: userId } } }, txId)
const seen = new Set<number>()
for (const p of [...asSender, ...asReceiver]) {
if (seen.has(p.serial_id)) continue
seen.add(p.serial_id)
await this.dbs.Delete<UserToUserPayment>('UserToUserPayment', p.serial_id, txId)
}
} }
async AddPendingUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, txId: string) { async AddPendingUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, txId: string) {
@ -447,8 +504,12 @@ export default class {
} }
} }
async GetTotalUsersBalance(txId?: string) { async GetTotalUsersBalance(excludeLocked?: boolean, txId?: string) {
const total = await this.dbs.Sum<User>('User', "balance_sats", {}) const where: { locked?: boolean } = {}
if (excludeLocked) {
where.locked = false
}
const total = await this.dbs.Sum<User>('User', "balance_sats", where, txId)
return total || 0 return total || 0
} }
@ -472,20 +533,31 @@ export default class {
return this.dbs.FindOne<TransactionSwap>('TransactionSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId) return this.dbs.FindOne<TransactionSwap>('TransactionSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId)
} }
async FinalizeTransactionSwap(swapOperationId: string, address: string, txId: string) { async SetTransactionSwapPaid(swapOperationId: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
return this.dbs.Update<TransactionSwap>('TransactionSwap', { swap_operation_id: swapOperationId }, { return this.dbs.Update<TransactionSwap>('TransactionSwap', { swap_operation_id: swapOperationId }, {
used: true, paid_at_unix: now,
tx_id: txId, }, txId)
address_paid: address,
})
} }
async FailTransactionSwap(swapOperationId: string, address: string, failureReason: string) { async FinalizeTransactionSwap(swapOperationId: string, address: string, chainTxId: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
return this.dbs.Update<TransactionSwap>('TransactionSwap', { swap_operation_id: swapOperationId }, {
used: true,
tx_id: chainTxId,
address_paid: address,
completed_at_unix: now,
}, txId)
}
async FailTransactionSwap(swapOperationId: string, address: string, failureReason: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
return this.dbs.Update<TransactionSwap>('TransactionSwap', { swap_operation_id: swapOperationId }, { return this.dbs.Update<TransactionSwap>('TransactionSwap', { swap_operation_id: swapOperationId }, {
used: true, used: true,
failure_reason: failureReason, failure_reason: failureReason,
address_paid: address, address_paid: address,
}) completed_at_unix: now,
}, txId)
} }
async DeleteTransactionSwap(swapOperationId: string, txId?: string) { async DeleteTransactionSwap(swapOperationId: string, txId?: string) {
@ -493,18 +565,18 @@ export default class {
} }
async DeleteExpiredTransactionSwaps(currentHeight: number, txId?: string) { async DeleteExpiredTransactionSwaps(currentHeight: number, txId?: string) {
return this.dbs.Delete<TransactionSwap>('TransactionSwap', { timeout_block_height: LessThan(currentHeight) }, txId) return this.dbs.Delete<TransactionSwap>('TransactionSwap', { timeout_block_height: LessThan(currentHeight), used: false }, txId)
} }
async ListPendingTransactionSwaps(appUserId: string, txId?: string) { async ListPendingTransactionSwaps(appUserId: string, txId?: string) {
return this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: false, app_user_id: appUserId } }, txId) return this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: false, app_user_id: appUserId } }, txId)
} }
async ListSwapPayments(userId: string, txId?: string) { async ListTxSwapPayments(userId: string, txId?: string) {
return this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), user: { user_id: userId } } }, txId) return this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), user: { user_id: userId } } }, txId)
} }
async ListCompletedSwaps(appUserId: string, payments: UserInvoicePayment[], txId?: string) { async ListCompletedTxSwaps(appUserId: string, payments: UserInvoicePayment[], txId?: string) {
const completed = await this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: true, app_user_id: appUserId } }, txId) const completed = await this.dbs.Find<TransactionSwap>('TransactionSwap', { where: { used: true, app_user_id: appUserId } }, txId)
// const payments = await this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), } }, txId) // const payments = await this.dbs.Find<UserInvoicePayment>('UserInvoicePayment', { where: { swap_operation_id: Not(IsNull()), } }, txId)
const paymentsMap = new Map<string, UserInvoicePayment>() const paymentsMap = new Map<string, UserInvoicePayment>()
@ -515,6 +587,85 @@ export default class {
swap: c, payment: paymentsMap.get(c.swap_operation_id) swap: c, payment: paymentsMap.get(c.swap_operation_id)
})) }))
} }
async AddInvoiceSwap(swap: Partial<InvoiceSwap>) {
return this.dbs.CreateAndSave<InvoiceSwap>('InvoiceSwap', swap)
}
async GetInvoiceSwap(swapOperationId: string, appUserId: string, txId?: string) {
const swap = await this.dbs.FindOne<InvoiceSwap>('InvoiceSwap', { where: { swap_operation_id: swapOperationId, used: false, app_user_id: appUserId } }, txId)
if (!swap || swap.tx_id) {
return null
}
return swap
}
async FinalizeInvoiceSwap(swapOperationId: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
return this.dbs.Update<InvoiceSwap>('InvoiceSwap', { swap_operation_id: swapOperationId }, {
used: true,
completed_at_unix: now,
}, txId)
}
async UpdateInvoiceSwap(swapOperationId: string, update: Partial<InvoiceSwap>, txId?: string) {
return this.dbs.Update<InvoiceSwap>('InvoiceSwap', { swap_operation_id: swapOperationId }, update, txId)
}
async SetInvoiceSwapTxId(swapOperationId: string, chainTxId: string, chainFeeSats: number, lockupTxHex?: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
const update: Partial<InvoiceSwap> = {
tx_id: chainTxId,
paid_at_unix: now,
chain_fee_sats: chainFeeSats,
}
if (lockupTxHex) {
update.lockup_tx_hex = lockupTxHex
}
return this.dbs.Update<InvoiceSwap>('InvoiceSwap', { swap_operation_id: swapOperationId }, update, txId)
}
async FailInvoiceSwap(swapOperationId: string, failureReason: string, txId?: string) {
const now = Math.floor(Date.now() / 1000)
return this.dbs.Update<InvoiceSwap>('InvoiceSwap', { swap_operation_id: swapOperationId }, {
used: true,
failure_reason: failureReason,
completed_at_unix: now,
}, txId)
}
async DeleteInvoiceSwap(swapOperationId: string, txId?: string) {
return this.dbs.Delete<InvoiceSwap>('InvoiceSwap', { swap_operation_id: swapOperationId }, txId)
}
async DeleteExpiredInvoiceSwaps(currentHeight: number, txId?: string) {
return this.dbs.Delete<InvoiceSwap>('InvoiceSwap', { timeout_block_height: LessThan(currentHeight), used: false, tx_id: "" }, txId)
}
async ListCompletedInvoiceSwaps(appUserId: string, txId?: string) {
return this.dbs.Find<InvoiceSwap>('InvoiceSwap', { where: { used: true, app_user_id: appUserId } }, txId)
}
async ListPendingInvoiceSwaps(appUserId: string, txId?: string) {
return this.dbs.Find<InvoiceSwap>('InvoiceSwap', { where: { used: false, app_user_id: appUserId } }, txId)
}
async ListUnfinishedInvoiceSwaps(txId?: string) {
const swaps = await this.dbs.Find<InvoiceSwap>('InvoiceSwap', { where: { used: false } }, txId)
return swaps.filter(s => !!s.tx_id)
}
async GetRefundableInvoiceSwap(swapOperationId: string, txId?: string) {
const swap = await this.dbs.FindOne<InvoiceSwap>('InvoiceSwap', { where: { swap_operation_id: swapOperationId } }, txId)
if (!swap || !swap.tx_id) {
return null
}
if (swap.used && !swap.failure_reason) {
return null
}
return swap
}
} }
const orFail = async <T>(resultPromise: Promise<T | null>) => { const orFail = async <T>(resultPromise: Promise<T | null>) => {

View file

@ -21,6 +21,14 @@ export default class {
} }
async RemoveUserProducts(userId: string, txId?: string) { async RemoveUserProducts(userId: string, txId?: string) {
return this.dbs.Delete<Product>('Product', { owner: { user_id: userId } }, txId) const products = await this.dbs.Find<Product>('Product', { where: { owner: { user_id: userId } } }, txId)
if (products.length === 0) {
return 0
}
let deleted = 0
for (const product of products) {
deleted += await this.dbs.Delete<Product>('Product', { product_id: product.product_id }, txId)
}
return deleted
} }
} }

View file

@ -53,13 +53,13 @@ export class TlvStorageFactory extends EventEmitter {
this.isConnected = false; this.isConnected = false;
}); });
this.process.on('exit', (code: number) => { this.process.on('exit', (code: number, signal: string) => {
this.log(ERROR, `Tlv Storage processor exited with code ${code}`); this.log(ERROR, `Tlv Storage processor exited with code ${code} and signal ${signal}`);
this.isConnected = false; this.isConnected = false;
if (!code) { if (code === 0) {
return return
} }
throw new Error(`Tlv Storage processor exited with code ${code}`) throw new Error(`Tlv Storage processor exited with code ${code} and signal ${signal}`)
}); });
this.isConnected = true; this.isConnected = true;
@ -173,7 +173,7 @@ export class TlvStorageFactory extends EventEmitter {
public disconnect() { public disconnect() {
if (this.process) { if (this.process) {
this.process.kill(); this.process.kill(0);
this.isConnected = false; this.isConnected = false;
this.debug = false; this.debug = false;
} }

View file

@ -126,7 +126,7 @@ class TlvFilesStorageProcessor {
throw new Error('Unknown metric type: ' + t) throw new Error('Unknown metric type: ' + t)
} }
}) })
this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
this.sendResponse({ this.sendResponse({
success: true, success: true,
type: 'nostrSend', type: 'nostrSend',

View file

@ -42,7 +42,7 @@ export default class {
async GetUser(userId: string, txId?: string): Promise<User> { async GetUser(userId: string, txId?: string): Promise<User> {
const user = await this.FindUser(userId, txId) const user = await this.FindUser(userId, txId)
if (!user) { if (!user) {
throw new Error(`user ${userId} not found`) // TODO: fix logs doxing throw new Error(`user not found`)
} }
return user return user
} }
@ -50,7 +50,7 @@ export default class {
async UnbanUser(userId: string, txId?: string) { async UnbanUser(userId: string, txId?: string) {
const affected = await this.dbs.Update<User>('User', { user_id: userId }, { locked: false }, txId) const affected = await this.dbs.Update<User>('User', { user_id: userId }, { locked: false }, txId)
if (!affected) { if (!affected) {
throw new Error("unaffected user unlock for " + userId) // TODO: fix logs doxing throw new Error("unaffected user unlock")
} }
} }
@ -58,7 +58,7 @@ export default class {
const user = await this.GetUser(userId, txId) const user = await this.GetUser(userId, txId)
const affected = await this.dbs.Update<User>('User', { user_id: userId }, { balance_sats: 0, locked: true }, txId) const affected = await this.dbs.Update<User>('User', { user_id: userId }, { balance_sats: 0, locked: true }, txId)
if (!affected) { if (!affected) {
throw new Error("unaffected ban user for " + userId) // TODO: fix logs doxing throw new Error("unaffected ban user")
} }
if (user.balance_sats > 0) { if (user.balance_sats > 0) {
this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: 'ban', amount: user.balance_sats }) this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: 'ban', amount: user.balance_sats })
@ -80,7 +80,7 @@ export default class {
const affected = await this.dbs.Increment<User>('User', { user_id: userId }, "balance_sats", increment, txId) const affected = await this.dbs.Increment<User>('User', { user_id: userId }, "balance_sats", increment, txId)
if (!affected) { if (!affected) {
getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by increment") getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by increment")
throw new Error("unaffected balance increment for " + userId) // TODO: fix logs doxing throw new Error("unaffected balance increment")
} }
getLogger({ userId: userId, component: "balanceUpdates" })("incremented balance from", user.balance_sats, "sats, by", increment, "sats") getLogger({ userId: userId, component: "balanceUpdates" })("incremented balance from", user.balance_sats, "sats, by", increment, "sats")
this.eventsLog.LogEvent({ type: 'balance_increment', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: increment }) this.eventsLog.LogEvent({ type: 'balance_increment', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: increment })
@ -105,7 +105,7 @@ export default class {
const affected = await this.dbs.Decrement<User>('User', { user_id: userId }, "balance_sats", decrement, txId) const affected = await this.dbs.Decrement<User>('User', { user_id: userId }, "balance_sats", decrement, txId)
if (!affected) { if (!affected) {
getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by decrement") getLogger({ userId: userId, component: "balanceUpdates" })("user unaffected by decrement")
throw new Error("unaffected balance decrement for " + userId) // TODO: fix logs doxing throw new Error("unaffected balance decrement")
} }
getLogger({ userId: userId, component: "balanceUpdates" })("decremented balance from", user.balance_sats, "sats, by", decrement, "sats") getLogger({ userId: userId, component: "balanceUpdates" })("decremented balance from", user.balance_sats, "sats, by", decrement, "sats")
this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: decrement }) this.eventsLog.LogEvent({ type: 'balance_decrement', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: decrement })
@ -126,4 +126,8 @@ export default class {
const lastSeenAtUnix = now - seconds const lastSeenAtUnix = now - seconds
return this.dbs.Find<UserAccess>('UserAccess', { where: { last_seen_at_unix: LessThan(lastSeenAtUnix) } }) return this.dbs.Find<UserAccess>('UserAccess', { where: { last_seen_at_unix: LessThan(lastSeenAtUnix) } })
} }
async DeleteUserAccess(userId: string, txId?: string) {
return this.dbs.Delete<UserAccess>('UserAccess', { user_id: userId }, txId)
}
} }

View file

@ -27,11 +27,11 @@ export default class webRTC {
attachNostrSend(f: NostrSend) { attachNostrSend(f: NostrSend) {
this._nostrSend = f this._nostrSend = f
} }
private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
if (!this._nostrSend) { if (!this._nostrSend) {
throw new Error("No nostrSend attached") throw new Error("No nostrSend attached")
} }
this._nostrSend(initiator, data, relays) await this._nostrSend(initiator, data, relays)
} }
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => { private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {