From d89a4a429abc4696cac4de3d451a4940bd85be86 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 15 Jul 2025 23:36:43 +0200 Subject: [PATCH] Wallet v2 (#2169) * Migrate vault entries to new schema (#2092) * Migrate existing vault entries to new schema * Read+write new vault schema * Drop VaultEntry table * Refactor vaultPrismaFragments * Remove wrong comment * Remove TODO * Fix possible race condition on update of vault key * Remove lib/object.js * Wallet schema v2 (#2146) * Add wallet-v2 TODOs * Update checkWallet * Wallet list * Delete almost all wallet v1 code and add some code for wallet protocol forms * Define protocol display name in JSON * Show form per protocol * Increase max-height of image in form * Add JSdoc for protocols, form validation * Use wallet cards again My wallet list was quite ugly and I couldn't look at it anymore. * Refactor hooks in wallet provider * Fix PasswordInput not used * Read encrypted wallets * Decrypt wallets * useWalletQuery now returns decrypted wallets * Refactor useIndexedDB because its only purpose will be to store the key, so no need for pagination code etc. * There is still a bug: if the wallet is not decrypted on first render, the form will not see the decrypted value. See TODO. * Rename protocolJson to protocol it no longer uses a JSON file * Fix form not updated with decrypted API key * Fix wallet template forms * Fix optional shown as hint * Rename to mapUserWalletResolveTypes * Save LNbits send and recv TODO: * implement resolvers for other protocols * fix double update required for trigger? * add missing validation on server * add missing network tests * don't import from wallets/client on server * Move definitions to lib/wallets.json and lib/protocols * Fix ProtocolWallet.updated_at not updated by trigger * Move wallet fragments into wallets/client/fragments/ * move invoice fragments to fragments/invoice.js * remove some unused fragments that I don't think I also will not use * move fragments that will be generated in own file * Move wallet resolvers into wallets/server/resolvers * Fix missing authorization check on wallet update * Run all shared code in generic wallet update function * Fix 'encrypt' flag not set for blink send currency * Add mutations for all protocols * Fix macaroon validation * Fix CLN socket value not set * Add server-side schema validation * Fix JSDoc typedef for protocols * Don't put JSDoc into separate file * Create test invoices on save * Also move type resolvers into wallets/server/resolvers * Fix unconfigured protocols of UserWallet not found * Fix Blink API key in wallet seed * Test send payment on save (except LNC) This does not include LNC because LNC cannot be saved yet * Check if window.webln is defined on save * Create new wallets from templates * Separate protocols in wallets/lib into individual files * Use justify-content-start for protocol tabs and larger margin at the top * Add LNC to client protocols * Only return wallets from useWallets * Query decrypted wallets * Payments with new wallets * More wallet logos * Fix TypeError in useIndexedDB * Add protocol attach docs * Fix undefined useWalletRecvPrompt import * Remove outdated TODOs * First successful zap to new wallets * Fix walletLogger imports * Fix sequences * the sequences for InvoiceForward and DirectPayment were still starting at 1 * when using setval() with two arguments, nextval() will return the second argument+1 (see https://www.postgresql.org/docs/current/functions-sequence.html) * Rename ProtocolWallet columns * Remove more outdated TODOs * Update wallet indicator * Fix page reset on route change * Refactor __typename checks into functions * Refactor protocol selection into own hook * Add button to detach protocol * Refetch wallet on save and detach * Refetch wallets on change * Always show all templates * Refactor WalletLink component * Also put wallet into forms context * Remove outdated TODOs * Use useMemo in wallets hooks * Passphrase modals * prompt for password if decryption failed * add button to reveal passphrase on wallet page TODO: * remove button if passphrase was revealed or imported * encrypt wallets with new key on passphrase reveal * Fix protocol missing as callback dependency * Encrypt wallets with new key on passphrase export * Update 'unlock wallets' text * Rename wallet mutation hooks * Remove 'removeWallet' mutation Wallets are automatically deleted when all protocols are deleted * Passphrase reset * Use 110px as minimum width for bip39 words longest bip39 words are 8 characters and they fit into 103px so I rounded up to 110px. * Also disable passphrase export on save * Wallet settings * Fix wallet receive prompt * Remove unused parameters from postgres function * Rename UserWallet to Wallet, ProtocolWallet to WalletProtocol * Use danger variant for button to show passphrase * Fix inconsistent imports and exports * Remove outdated TODOs * wallet logs * Remove outdated comment * Make sure wallets are used in priority order * Separate wallets from templates in reducer * Fix missing useCallback dependencies * Refactor with useWalletLogger hook * Move enabled to WalletProtocol * Add checkbox to enable/disable protocol * Fix migration with prod db dump * Parse Coinos relay URLs * Skip network tests if only enabled changed * Allow IndexedDB calls without session * Add code to migrate old CryptoKey * first try to use existing CryptoKey before generating a new one * bump IDB version to delete old object stores and create new ones * return IDB callbacks with useMemo * don't delete old IDB right away, wait until next release * Fix ghost import error *Sometimes*, I get import errors because it tries to resolve @/wallets/server to wallets/server.js instead of wallets/server/index.js. For the files in wallets/server, it kind of makes sense because it's a circular import. But I don't know why the files in worker/ have this problem. Interestingly, it only seems to happen with walletLogger imports, so I guess its related to its import chain. Anyway, this commit should make sure this never happens again ... * Skip wallets queries if not logged in * Split CUSTOM wallet into NWC and LN_ADDR * Migrate local wallets * Link to /wallets/:id/receive if send not supported * Hide separator if there are no configured wallets * Save LNC * Add one-liner to attach LNC * Update wallet priorities via DnD * Wallet logs are part of protocol resolvers * Fix logging to deleted protocol * Fix trying to fetch logs for template * also change type to Int so GraphQL layer can catch trying to fetch string IDs as is the case for templates * Fix embedded flag for wallets logs not set * Remove TODO * Decrease max-height for embedded wallet logs on big screens * Fix missing refetch on wallet priority update * Set priorities of all wallets in one tx * Fix nested state update * Add DragIcon * DnD mobile support and refactor * Add CancelButton to wallet settings * Remount form if path changes This fixes the following warning in the console: """ Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. """ * Support string and object for wallet.image JSON * Append domain to lightning address inputs * Remove outdated TODOs * Add template IDs to wallet JSON * Fix missing callback dependency * Implement lightning address save in receive prompt * Update TODOs * Fix missing check for enabled * Pay QR codes with WebLN as anon * Add logo for NWC * Fix trying to save logs for template * Add template logs * Fix inconsistent margin * Always throw on missing key * Remove misleading comment Wallets are returned even if decryption fails so we can show the unlock page if a wallet is stored as encrypted in the context. Maybe I should rethink this. * Check for existing wallets on local wallet migration * Fix local wallet migration causing duplicates * Fix protocol reattached on detach due to migration * Fix form not centered * Fix ZEUS lightning address domain * Add placeholder, help, hints etc. to wallet form inputs * Fix wallet badges not updated * Remove unused declared variables * Rename to ATTACH_PAGE * Fix 500 error if no amount was given to LNURLp endpoint * Tag log messages with wallet name * Only skip network tests if we're disabling the wallet * Rename var to networkTests * Continue to store key hash in IndexedDB * Rethink wallet state management If decryption failed, the function to decrypt the wallets didn't throw but simply returned wallets that were still encrypted. This was bad because it meant we could not rely on the wallets in the state being decrypted, even though this was the original idea behind the query hooks: hide the details of encryption and decryption inside them. Because of this, we had to check if the wallets were still encrypted before we ran the wallet migration since we want to check if a protocol already exists. This commit fixes this by making encryption and decryption always throw (and catching the errors), as well as returning a ready state from hooks. A hook might not be ready because it still needs to load something (in the case of the crypto hooks, it's loading the key from IndexedDB). Callers check that ready state before they call the function returned by the hook. So now, the wallet migration hook can itself simply check if the hook to encrypt wallets is ready and if the wallets are no longer loading to let callers know if it itself is ready. Since we also relied on wallets stored as encrypted in the context to show the unlock page, this was also changed by comparing the local and remote key hash. * Add empty line * Save new key hash during wallet reset * Only receive protocol upserts require networkTests param * Compare key hashes on server on each save * Delete old code * Fix card shows attach instead of configure * Fix empty wallets created during migration The old schema can contain '' instead of NULL in the columns of wallets for receiving. * Update reset passphrase text * Wrap passphrase reset in try/catch * Fix migrate called multiple times * Update key hash on migration if not set * Fetch local wallets in migrate * Fix missing await on setKey * Let first device set key hash * Fix indicator not shown if wallets locked * Check if IndexedDB is available * Fix inconsistent WebLN error message * Disable WebLN if not available * Remove outdated TODO * Cursor-based pagination for wallet logs * Fix log message x-overflow * Add context to wallet logs * Wrap errors are warnings in logs * Rename wallet v2 migrations * Update wallet status during logging * Fix wallet logs loading state The loading state would go from false -> true -> false because it's false when the lazy query wasn't called yet. * Add wallet search * Add Alby Go wallet * Revert "Add Alby Go wallet" This reverts commit 926c70638f1673756480c848237e52d5889dc037. * Fix wallet logs sent by client don't update protocol status * Fix mutation name * put drag icon on opposite corner * Add wallets/README.md * Fix inconsistent case in wallets/README.md * Fix autoprefixer warning about mixed support This warning was in the app logs: app | Warning app | app | (31:3) autoprefixer: end value has mixed support, consider using flex-end instead app | app | Import trace for requested module: app | ./styles/wallet.module.css app | ./wallets/client/hooks/prompt.js app | ./wallets/client/hooks/index.js app | ./wallets/client/context/hooks.js app | ./wallets/client/context/provider.js app | ./wallets/client/context/index.js * fix effect of wallet indicators on logo * Fix deleting wallet template logs * Use name as primary key of WalletTemplate * Fix wallet_clear_vault trigger not mentioned in README * Fix wallet receive prompt Also remove no longer needed templateId from wallets.json and helper functions * Use findUnique since name is now primary key * Merge Alby wallets into one * Remove unused name parameter from WalletsForm component * Fix number check to decide if wallet or template * Update wallet encryption on click, not as effect * add cashu.me and lightning address logos * add images * Use recommended typeof to check if IDB available * Also check if IDB available on delete * Use constraint triggers * Add indices on columns used for joins * Fix inconsistent CLEAR OR REPLACE TRIGGER * Attach wallet_check_support trigger to WalletProtocol table * Update wallets/README.md * Remove debugging code * Refactor reducer: replace page with status * Show 'wallets unavailable' if device does not support IndexedDB * Remove duplicate ELSIF condition * Fix hasSendWallet The useSendWallets hook was not checking if the returned send wallets are enabled. Since the components that used that hook only need to know if there is a send wallet, I replaced the useSendWallets hook with a useHasSendWallet hook. * Add Cash App wallet * fix changes loglevel enum * Fix key init race condition in strict mode if no key exists yet * Formatting * Fix key init race condition via transactions in readwrite mode * Replace Promise.withResolvers with regular promises * replace generic spinner with our usual --------- Co-authored-by: k00b --- .gitignore | 1 + api/paidAction/index.js | 26 +- api/paidAction/zap.js | 4 +- api/payingAction/index.js | 8 +- api/resolvers/index.js | 8 +- api/resolvers/vault.js | 53 - api/resolvers/wallet.js | 395 +----- api/typeDefs/index.js | 3 +- api/typeDefs/user.js | 10 +- api/typeDefs/vault.js | 28 - api/typeDefs/wallet.js | 283 +++-- components/autowithdraw-shared.js | 76 -- components/banners.js | 17 - components/form.js | 44 +- components/invoice.js | 4 +- components/item-act.js | 22 +- components/log-message.js | 62 - components/modal.js | 6 + components/nav/common.js | 8 +- components/nav/mobile/offcanvas.js | 2 +- components/pay-bounty.js | 6 +- components/use-indexeddb.js | 433 +++---- components/use-invoice.js | 4 +- components/use-item-submit.js | 2 +- components/use-paid-mutation.js | 4 +- components/use-qr-payment.js | 6 +- components/vault/use-vault-configurator.js | 175 --- components/vault/use-vault.js | 64 - docker-compose.yml | 1 + docker/db/wallet-seed.sql | 186 +++ fragments/{wallet.js => invoice.js} | 84 -- fragments/notifications.js | 2 +- fragments/paidAction.js | 2 +- fragments/users.js | 8 +- fragments/vault.js | 33 - lib/apollo.js | 10 + lib/url.js | 26 - lib/validate.js | 36 +- lib/yup.js | 48 +- pages/_app.js | 41 +- pages/api/lnurlp/[username]/pay.js | 12 +- pages/directs/[id].js | 2 +- pages/invoices/[id].js | 2 +- pages/satistics/index.js | 2 +- pages/settings/index.js | 42 +- pages/settings/passphrase/index.js | 211 ---- pages/wallets/[...slug].js | 20 + pages/wallets/[wallet].js | 187 --- pages/wallets/index.js | 247 ++-- pages/wallets/logs.js | 17 +- pages/wallets/settings.js | 185 +++ pages/withdraw.js | 2 +- pages/withdrawals/[id].js | 2 +- .../migration.sql | 208 ++++ .../20250702000001_wallet_v2/migration.sql | 1091 +++++++++++++++++ prisma/schema.prisma | 465 ++++--- public/wallets/alby-dark.svg | 24 + public/wallets/alby.svg | 24 + public/wallets/blixt-dark.svg | 20 + public/wallets/blixt.svg | 20 + public/wallets/cashapp-dark.webp | Bin 0 -> 21632 bytes public/wallets/cashapp.webp | Bin 0 -> 21632 bytes public/wallets/cashu.me-dark.png | Bin 0 -> 25434 bytes public/wallets/cashu.me.png | Bin 0 -> 24564 bytes public/wallets/coinos-dark.svg | 6 + public/wallets/coinos.svg | 1 + public/wallets/fountain-dark.png | Bin 0 -> 14055 bytes public/wallets/fountain.png | Bin 0 -> 13634 bytes public/wallets/lifpay-dark.jpg | Bin 0 -> 7451 bytes public/wallets/lifpay-dark.png | Bin 0 -> 10449 bytes public/wallets/lifpay.png | Bin 0 -> 10545 bytes public/wallets/lnaddr-dark.png | Bin 0 -> 10302 bytes public/wallets/lnaddr.png | Bin 0 -> 9237 bytes public/wallets/minibits-dark.png | Bin 0 -> 3970 bytes public/wallets/minibits.png | Bin 0 -> 3647 bytes public/wallets/npub-cash-dark.svg | 22 + public/wallets/npub-cash.svg | 22 + public/wallets/nwc-dark.png | Bin 0 -> 36852 bytes public/wallets/nwc.png | Bin 0 -> 36519 bytes public/wallets/primal-dark.svg | 25 + public/wallets/primal.svg | 25 + public/wallets/rizful-dark.png | Bin 0 -> 3264 bytes public/wallets/rizful.png | Bin 0 -> 4278 bytes public/wallets/shockwallet-dark.png | Bin 0 -> 26437 bytes public/wallets/shockwallet.png | Bin 0 -> 40888 bytes public/wallets/speed-dark.svg | 9 + public/wallets/speed.svg | 9 + public/wallets/strike-dark.png | Bin 0 -> 2916 bytes public/wallets/strike.png | Bin 0 -> 2786 bytes public/wallets/voltage-dark.svg | 18 + public/wallets/voltage.svg | 11 + public/wallets/wos-dark.svg | 1 + public/wallets/wos.svg | 1 + public/wallets/zbd-dark.png | Bin 0 -> 9383 bytes public/wallets/zbd.png | Bin 0 -> 9586 bytes public/wallets/zbd.svg | 23 + public/wallets/zeus-dark.svg | 14 + public/wallets/zeus.svg | 14 + styles/dnd.module.css | 31 + styles/{log.module.css => logger.module.css} | 48 +- styles/wallet.module.css | 128 +- svgs/lock-line.svg | 1 + wallets/README.md | 610 +++++---- wallets/blink/common.js | 67 - wallets/blink/index.js | 71 -- wallets/buttonbar.js | 24 - wallets/card.js | 56 - wallets/client.js | 11 - wallets/client/components/card.js | 71 ++ wallets/client/components/draggable.js | 44 + wallets/client/components/forms.js | 359 ++++++ wallets/client/components/index.js | 6 + wallets/client/components/layout.js | 58 + wallets/client/components/logger.js | 145 +++ wallets/client/components/passphrase.js | 37 + wallets/client/components/search.js | 41 + wallets/client/context/dnd.js | 235 ++++ wallets/client/context/hooks.js | 245 ++++ wallets/client/context/index.js | 7 + wallets/client/context/provider.js | 81 ++ wallets/client/context/reducer.js | 73 ++ wallets/{ => client}/errors.js | 0 wallets/client/fragments/index.js | 2 + wallets/client/fragments/protocol.js | 111 ++ wallets/client/fragments/wallet.js | 259 ++++ wallets/client/hooks/crypto.js | 355 ++++++ wallets/client/hooks/image.js | 18 + wallets/client/hooks/index.js | 8 + wallets/client/hooks/indicator.js | 7 + wallets/client/hooks/logger.js | 227 ++++ wallets/{ => client/hooks}/payment.js | 71 +- wallets/{ => client/hooks}/prompt.js | 70 +- wallets/client/hooks/query.js | 514 ++++++++ wallets/client/hooks/wallet.js | 49 + .../client.js => client/protocols/blink.js} | 19 +- wallets/client/protocols/index.js | 56 + .../client.js => client/protocols/lnbits.js} | 34 +- .../client.js => client/protocols/lnc.js} | 19 +- wallets/client/protocols/nwc.js | 15 + .../protocols/phoenixd.js} | 22 +- wallets/client/protocols/util.js | 13 + wallets/client/protocols/webln.js | 33 + wallets/cln/client.js | 1 - wallets/cln/index.js | 72 -- wallets/common.js | 174 --- wallets/config.js | 154 --- wallets/graphql.js | 51 - wallets/image.js | 14 - wallets/index.js | 317 ----- wallets/indicator.js | 6 - wallets/lib/protocols/blink.js | 137 +++ wallets/lib/protocols/clnRest.js | 51 + .../ATTACH.md => lib/protocols/docs/cln.md} | 2 +- .../protocols/docs/lnAddr.md} | 0 .../protocols/docs/lnbits.md} | 0 .../ATTACH.md => lib/protocols/docs/lnc.md} | 8 +- .../protocols/docs/lndGrpc.md} | 2 +- .../ATTACH.md => lib/protocols/docs/nwc.md} | 2 +- .../ATTACH.md => lib/protocols/docs/webln.md} | 4 +- wallets/lib/protocols/index.js | 48 + wallets/lib/protocols/lnAddr.js | 20 + wallets/lib/protocols/lnbits.js | 52 + wallets/lib/protocols/lnc.js | 41 + wallets/lib/protocols/lndGrpc.js | 49 + wallets/lib/protocols/nwc.js | 71 ++ wallets/lib/protocols/phoenixd.js | 62 + wallets/lib/protocols/webln.js | 38 + wallets/lib/util.js | 116 ++ wallets/lib/validate.js | 175 +++ wallets/lib/wallets.json | 161 +++ wallets/lightning-address/client.js | 1 - wallets/lightning-address/index.js | 22 - wallets/lnbits/index.js | 59 - wallets/lnc/index.js | 64 - wallets/lnd/client.js | 1 - wallets/lnd/index.js | 54 - wallets/logger.js | 360 ------ wallets/nwc/client.js | 14 - wallets/nwc/index.js | 69 -- wallets/nwc/server.js | 29 - wallets/phoenixd/index.js | 42 - wallets/server/index.js | 3 + wallets/server/logger.js | 72 ++ .../server.js => server/protocols/blink.js} | 35 +- .../server.js => server/protocols/clnRest.js} | 17 +- wallets/server/protocols/index.js | 60 + .../server.js => server/protocols/lnAddr.js} | 10 +- .../server.js => server/protocols/lnbits.js} | 18 +- .../server.js => server/protocols/lndGrpc.js} | 14 +- wallets/server/protocols/nwc.js | 20 + .../protocols/phoenixd.js} | 20 +- wallets/server/protocols/util.js | 13 + wallets/{server.js => server/receive.js} | 91 +- wallets/server/resolvers/index.js | 16 + wallets/server/resolvers/protocol.js | 373 ++++++ wallets/server/resolvers/util.js | 40 + wallets/server/resolvers/wallet.js | 227 ++++ wallets/{ => server}/wrap.js | 4 +- wallets/status.js | 54 - wallets/support.js | 8 - wallets/validate.js | 117 -- wallets/webln/client.js | 57 - wallets/webln/index.js | 16 - worker/autowithdraw.js | 6 +- worker/index.js | 4 +- worker/paidAction.js | 61 +- worker/payingAction.js | 20 +- worker/wallet.js | 73 -- 208 files changed, 8568 insertions(+), 4990 deletions(-) delete mode 100644 api/resolvers/vault.js delete mode 100644 api/typeDefs/vault.js delete mode 100644 components/autowithdraw-shared.js delete mode 100644 components/log-message.js delete mode 100644 components/vault/use-vault-configurator.js delete mode 100644 components/vault/use-vault.js create mode 100644 docker/db/wallet-seed.sql rename fragments/{wallet.js => invoice.js} (62%) delete mode 100644 fragments/vault.js delete mode 100644 pages/settings/passphrase/index.js create mode 100644 pages/wallets/[...slug].js delete mode 100644 pages/wallets/[wallet].js create mode 100644 pages/wallets/settings.js create mode 100644 prisma/migrations/20250702000000_vault_refactor/migration.sql create mode 100644 prisma/migrations/20250702000001_wallet_v2/migration.sql create mode 100644 public/wallets/alby-dark.svg create mode 100644 public/wallets/alby.svg create mode 100644 public/wallets/blixt-dark.svg create mode 100644 public/wallets/blixt.svg create mode 100644 public/wallets/cashapp-dark.webp create mode 100644 public/wallets/cashapp.webp create mode 100644 public/wallets/cashu.me-dark.png create mode 100644 public/wallets/cashu.me.png create mode 100644 public/wallets/coinos-dark.svg create mode 100644 public/wallets/coinos.svg create mode 100644 public/wallets/fountain-dark.png create mode 100644 public/wallets/fountain.png create mode 100644 public/wallets/lifpay-dark.jpg create mode 100644 public/wallets/lifpay-dark.png create mode 100644 public/wallets/lifpay.png create mode 100644 public/wallets/lnaddr-dark.png create mode 100644 public/wallets/lnaddr.png create mode 100644 public/wallets/minibits-dark.png create mode 100644 public/wallets/minibits.png create mode 100644 public/wallets/npub-cash-dark.svg create mode 100644 public/wallets/npub-cash.svg create mode 100644 public/wallets/nwc-dark.png create mode 100644 public/wallets/nwc.png create mode 100644 public/wallets/primal-dark.svg create mode 100644 public/wallets/primal.svg create mode 100644 public/wallets/rizful-dark.png create mode 100644 public/wallets/rizful.png create mode 100644 public/wallets/shockwallet-dark.png create mode 100644 public/wallets/shockwallet.png create mode 100644 public/wallets/speed-dark.svg create mode 100644 public/wallets/speed.svg create mode 100644 public/wallets/strike-dark.png create mode 100644 public/wallets/strike.png create mode 100644 public/wallets/voltage-dark.svg create mode 100644 public/wallets/voltage.svg create mode 100644 public/wallets/wos-dark.svg create mode 100644 public/wallets/wos.svg create mode 100644 public/wallets/zbd-dark.png create mode 100644 public/wallets/zbd.png create mode 100644 public/wallets/zbd.svg create mode 100644 public/wallets/zeus-dark.svg create mode 100644 public/wallets/zeus.svg create mode 100644 styles/dnd.module.css rename styles/{log.module.css => logger.module.css} (52%) create mode 100644 svgs/lock-line.svg delete mode 100644 wallets/blink/common.js delete mode 100644 wallets/blink/index.js delete mode 100644 wallets/buttonbar.js delete mode 100644 wallets/card.js delete mode 100644 wallets/client.js create mode 100644 wallets/client/components/card.js create mode 100644 wallets/client/components/draggable.js create mode 100644 wallets/client/components/forms.js create mode 100644 wallets/client/components/index.js create mode 100644 wallets/client/components/layout.js create mode 100644 wallets/client/components/logger.js create mode 100644 wallets/client/components/passphrase.js create mode 100644 wallets/client/components/search.js create mode 100644 wallets/client/context/dnd.js create mode 100644 wallets/client/context/hooks.js create mode 100644 wallets/client/context/index.js create mode 100644 wallets/client/context/provider.js create mode 100644 wallets/client/context/reducer.js rename wallets/{ => client}/errors.js (100%) create mode 100644 wallets/client/fragments/index.js create mode 100644 wallets/client/fragments/protocol.js create mode 100644 wallets/client/fragments/wallet.js create mode 100644 wallets/client/hooks/crypto.js create mode 100644 wallets/client/hooks/image.js create mode 100644 wallets/client/hooks/index.js create mode 100644 wallets/client/hooks/indicator.js create mode 100644 wallets/client/hooks/logger.js rename wallets/{ => client/hooks}/payment.js (74%) rename wallets/{ => client/hooks}/prompt.js (69%) create mode 100644 wallets/client/hooks/query.js create mode 100644 wallets/client/hooks/wallet.js rename wallets/{blink/client.js => client/protocols/blink.js} (94%) create mode 100644 wallets/client/protocols/index.js rename wallets/{lnbits/client.js => client/protocols/lnbits.js} (69%) rename wallets/{lnc/client.js => client/protocols/lnc.js} (99%) create mode 100644 wallets/client/protocols/nwc.js rename wallets/{phoenixd/client.js => client/protocols/phoenixd.js} (74%) create mode 100644 wallets/client/protocols/util.js create mode 100644 wallets/client/protocols/webln.js delete mode 100644 wallets/cln/client.js delete mode 100644 wallets/cln/index.js delete mode 100644 wallets/common.js delete mode 100644 wallets/config.js delete mode 100644 wallets/graphql.js delete mode 100644 wallets/image.js delete mode 100644 wallets/index.js delete mode 100644 wallets/indicator.js create mode 100644 wallets/lib/protocols/blink.js create mode 100644 wallets/lib/protocols/clnRest.js rename wallets/{cln/ATTACH.md => lib/protocols/docs/cln.md} (93%) rename wallets/{lightning-address/ATTACH.md => lib/protocols/docs/lnAddr.md} (100%) rename wallets/{lnbits/ATTACH.md => lib/protocols/docs/lnbits.md} (100%) rename wallets/{lnc/ATTACH.md => lib/protocols/docs/lnc.md} (70%) rename wallets/{lnd/ATTACH.md => lib/protocols/docs/lndGrpc.md} (79%) rename wallets/{nwc/ATTACH.md => lib/protocols/docs/nwc.md} (84%) rename wallets/{webln/ATTACH.md => lib/protocols/docs/webln.md} (93%) create mode 100644 wallets/lib/protocols/index.js create mode 100644 wallets/lib/protocols/lnAddr.js create mode 100644 wallets/lib/protocols/lnbits.js create mode 100644 wallets/lib/protocols/lnc.js create mode 100644 wallets/lib/protocols/lndGrpc.js create mode 100644 wallets/lib/protocols/nwc.js create mode 100644 wallets/lib/protocols/phoenixd.js create mode 100644 wallets/lib/protocols/webln.js create mode 100644 wallets/lib/util.js create mode 100644 wallets/lib/validate.js create mode 100644 wallets/lib/wallets.json delete mode 100644 wallets/lightning-address/client.js delete mode 100644 wallets/lightning-address/index.js delete mode 100644 wallets/lnbits/index.js delete mode 100644 wallets/lnc/index.js delete mode 100644 wallets/lnd/client.js delete mode 100644 wallets/lnd/index.js delete mode 100644 wallets/logger.js delete mode 100644 wallets/nwc/client.js delete mode 100644 wallets/nwc/index.js delete mode 100644 wallets/nwc/server.js delete mode 100644 wallets/phoenixd/index.js create mode 100644 wallets/server/index.js create mode 100644 wallets/server/logger.js rename wallets/{blink/server.js => server/protocols/blink.js} (95%) rename wallets/{cln/server.js => server/protocols/clnRest.js} (69%) create mode 100644 wallets/server/protocols/index.js rename wallets/{lightning-address/server.js => server/protocols/lnAddr.js} (96%) rename wallets/{lnbits/server.js => server/protocols/lnbits.js} (86%) rename wallets/{lnd/server.js => server/protocols/lndGrpc.js} (95%) create mode 100644 wallets/server/protocols/nwc.js rename wallets/{phoenixd/server.js => server/protocols/phoenixd.js} (80%) create mode 100644 wallets/server/protocols/util.js rename wallets/{server.js => server/receive.js} (70%) create mode 100644 wallets/server/resolvers/index.js create mode 100644 wallets/server/resolvers/protocol.js create mode 100644 wallets/server/resolvers/util.js create mode 100644 wallets/server/resolvers/wallet.js rename wallets/{ => server}/wrap.js (97%) delete mode 100644 wallets/status.js delete mode 100644 wallets/support.js delete mode 100644 wallets/validate.js delete mode 100644 wallets/webln/client.js delete mode 100644 wallets/webln/index.js diff --git a/.gitignore b/.gitignore index a01a1b4d..e7568828 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ docker-compose.*.yml *.sql !/prisma/migrations/*/*.sql !/docker/db/seed.sql +!/docker/db/wallet-seed.sql # nostr wallet connect scripts/nwc-keys.json diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cd627987..0153bf64 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) { await assertBelowMaxPendingInvoices(incomingContext) const description = await paidActions[actionType].describe(args, incomingContext) - const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { + const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, { msats: cost, feePercent: sybilFeePercent, description, @@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) { invoiceArgs: { bolt11: invoice, wrappedBolt11: wrappedInvoice, - wallet, + protocol, maxFee } } @@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) { const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext) - for await (const { invoice, logger, wallet } of createUserInvoice(userId, { + for await (const { invoice, logger, protocol } of createUserInvoice(userId, { msats: cost, description, expiry: INVOICE_EXPIRE_SECS @@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) { bolt11: invoice, msats: cost, hash, - walletId: wallet.id, + protocolId: protocol.id, receiverId: userId } }), @@ -346,22 +346,26 @@ export async function retryPaidAction (actionType, args, incomingContext) { invoiceId: failedInvoice.id }, include: { - wallet: true + protocol: { + include: { + wallet: true + } + } } }) if (invoiceForward) { // this is a wrapped invoice, we need to retry it with receiver fallbacks try { - const { userId } = invoiceForward.wallet + const { userId } = invoiceForward.protocol.wallet // this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available - const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, { + const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, { msats: failedInvoice.msatsRequested, feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext), description: await action.describe?.(actionArgs, retryContext), expiry: INVOICE_EXPIRE_SECS }, retryContext) - invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee } + invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee } } catch (err) { console.log('failed to retry wrapped invoice, falling back to SN:', err) } @@ -429,7 +433,7 @@ async function createSNInvoice (actionType, args, context) { async function createDbInvoice (actionType, args, context) { const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context - const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs + const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs const db = tx ?? models @@ -468,9 +472,9 @@ async function createDbInvoice (actionType, args, context) { invoice: { create: invoiceData }, - wallet: { + protocol: { connect: { - id: wallet.id + id: protocol.id } } } diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 66b4d6a0..5ebecba9 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, return null } - const wallets = await getInvoiceableWallets(item.userId, { models }) + const protocols = await getInvoiceableWallets(item.userId, { models }) // request peer invoice if they have an attached wallet and have not forwarded the item // and the receiver doesn't want to receive credits - if (wallets.length > 0 && + if (protocols.length > 0 && item.itemForwards.length === 0 && sats >= item.user.receiveCreditsBelowSats) { return item.userId diff --git a/api/payingAction/index.js b/api/payingAction/index.js index 2ff7117a..cdc6db5c 100644 --- a/api/payingAction/index.js +++ b/api/payingAction/index.js @@ -6,9 +6,9 @@ import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service' // paying actions are completely distinct from paid actions // and there's only one paying action: send // ... still we want the api to at least be similar -export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) { +export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) { try { - console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId) + console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId) if (!me) { throw new Error('You must be logged in to perform this action') @@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId }, msatsPaying: toPositiveBigInt(decoded.mtokens), msatsFeePaying: satsToMsats(maxFee), userId: me.id, - walletId, - autoWithdraw: !!walletId + protocolId, + autoWithdraw: !!protocolId } }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d..65794f8e 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -1,7 +1,8 @@ import user from './user' import message from './message' import item from './item' -import wallet from './wallet' +import walletV1 from './wallet' +import walletV2 from '@/wallets/server/resolvers' import lnurl from './lnurl' import notifications from './notifications' import invite from './invite' @@ -19,7 +20,6 @@ import chainFee from './chainFee' import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' -import vault from './vault' const date = new GraphQLScalarType({ name: 'Date', @@ -54,6 +54,6 @@ const limit = createIntScalar({ maximum: 1000 }) -export default [user, item, message, wallet, lnurl, notifications, invite, sub, +export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] + { JSONObject }, { Date: date }, { Limit: limit }, paidAction] diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js deleted file mode 100644 index 1211adaf..00000000 --- a/api/resolvers/vault.js +++ /dev/null @@ -1,53 +0,0 @@ -import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' - -export default { - Query: { - getVaultEntries: async (parent, args, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - - return await models.vaultEntry.findMany({ where: { userId: me.id } }) - } - }, - Mutation: { - // atomic vault migration - updateVaultKey: async (parent, { entries, hash }, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - if (!hash) throw new GqlInputError('hash required') - const txs = [] - - const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) - if (oldKeyHash) { - if (oldKeyHash !== hash) { - throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) - } else { - return true - } - } else { - txs.push(models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: hash } - })) - } - - for (const entry of entries) { - txs.push(models.vaultEntry.update({ - where: { userId_key: { userId: me.id, key: entry.key } }, - data: { value: entry.value, iv: entry.iv } - })) - } - await models.$transaction(txs) - return true - }, - clearVault: async (parent, args, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - const txs = [] - txs.push(models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: '' } - })) - txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } })) - await models.$transaction(txs) - return true - } - } -} diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c97eac79..67d0a400 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -8,7 +8,6 @@ import { SELECT, itemQueryWithMeta } from './item' import { formatMsats, msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { USER_ID, INVOICE_RETENTION_DAYS, - WALLET_CREATE_INVOICE_TIMEOUT_MS, WALLET_RETRY_AFTER_MS, WALLET_RETRY_BEFORE_MS, WALLET_MAX_RETRIES @@ -18,76 +17,12 @@ import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from '@/worker/wallet' -import walletDefs from '@/wallets/server' -import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets } from '../lnd' -import validateWallet from '@/wallets/validate' -import { canReceive, getWalletByType } from '@/wallets/common' import performPaidAction from '../paidAction' import performPayingAction from '../payingAction' -import { timeoutSignal, withTimeout } from '@/lib/time' - -function injectResolvers (resolvers) { - console.group('injected GraphQL resolvers:') - for (const walletDef of walletDefs) { - const resolverName = generateResolverName(walletDef.walletField) - console.log(resolverName) - resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => { - console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data }) - - let existingVaultEntries - if (typeof vaultEntries === 'undefined' && data.id) { - // this mutation was sent from an unsynced client - // to pass validation, we need to add the existing vault entries for validation - // in case the client is removing the receiving config - existingVaultEntries = await models.vaultEntry.findMany({ - where: { - walletId: Number(data.id) - } - }) - } - - const validData = await validateWallet(walletDef, - { ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries }, - { serverSide: true }) - if (validData) { - data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] }) - settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] }) - } - - // wallet in shape of db row - const wallet = { - field: walletDef.walletField, - type: walletDef.walletType, - userId: me?.id - } - const logger = walletLogger({ wallet, models }) - - return await upsertWallet({ - wallet, - walletDef, - testCreateInvoice: - walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data }) - ? (data) => withTimeout( - walletDef.testCreateInvoice(data, { - logger, - signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) - }), - WALLET_CREATE_INVOICE_TIMEOUT_MS) - : null - }, { - settings, - data, - vaultEntries - }, { logger, me, models }) - } - } - console.groupEnd() - - return resolvers -} +import { logContextFromBolt11 } from '@/wallets/server' export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ @@ -153,23 +88,6 @@ export function verifyHmac (hash, hmac) { const resolvers = { Query: { invoice: getInvoice, - wallets: async (parent, args, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - return await models.wallet.findMany({ - include: { - vaultEntries: true - }, - where: { - userId: me.id - }, - orderBy: { - priority: 'asc' - } - }) - }, withdrawl: getWithdrawl, direct: async (parent, { id }, { me, models }) => { if (!me) { @@ -375,67 +293,6 @@ const resolvers = { facts: history } }, - walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - // we cursoring with the wallet logs on the client - // if we have from, don't use cursor - // regardless, store the state of the cursor for the next call - - const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() } - - let logs = [] - let nextCursor - if (from) { - logs = await models.walletLog.findMany({ - where: { - userId: me.id, - wallet: type ?? undefined, - createdAt: { - gt: from ? new Date(Number(from)) : undefined, - lte: to ? new Date(Number(to)) : undefined - } - }, - include: { - invoice: true, - withdrawal: true - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' } - ] - }) - nextCursor = nextCursorEncoded(decodedCursor, logs.length) - } else { - logs = await models.walletLog.findMany({ - where: { - userId: me.id, - wallet: type ?? undefined, - createdAt: { - lte: decodedCursor.time - } - }, - include: { - invoice: true, - withdrawal: true - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' } - ], - take: LIMIT, - skip: decodedCursor.offset - }) - nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null - } - - return { - cursor: nextCursor, - entries: logs - } - }, failedInvoices: async (parent, args, { me, models }) => { if (!me) { throw new GqlAuthenticationError() @@ -459,17 +316,6 @@ const resolvers = { ORDER BY id DESC` } }, - Wallet: { - wallet: async (wallet) => { - return { - ...wallet.wallet, - __resolveType: generateTypeDefName(wallet.type) - } - } - }, - WalletDetails: { - __resolveType: wallet => wallet.__resolveType - }, InvoiceOrDirect: { __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType }, @@ -534,43 +380,6 @@ const resolvers = { return true }, - setWalletPriority: async (parent, { id, priority }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } }) - - return true - }, - removeWallet: async (parent, { id }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } }) - if (!wallet) { - throw new GqlInputError('wallet not found') - } - - const logger = walletLogger({ wallet, models }) - await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) - - if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) { - logger.info('details for receiving deleted') - } - - return true - }, - deleteWalletLogs: async (parent, { wallet }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - await models.walletLog.deleteMany({ where: { userId: me.id, wallet } }) - - return true - }, buyCredits: async (parent, { credits }, { me, models, lnd }) => { return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd }) } @@ -736,205 +545,9 @@ const resolvers = { } } -export default injectResolvers(resolvers) +export default resolvers -const logContextFromBolt11 = async (bolt11) => { - const decoded = await parsePaymentRequest({ request: bolt11 }) - return { - bolt11, - amount: formatMsats(decoded.mtokens), - payment_hash: decoded.id, - created_at: decoded.created_at, - expires_at: decoded.expires_at, - description: decoded.description - } -} - -export const walletLogger = ({ wallet, models, me }) => { - // no-op logger if no wallet or user provided - if (!wallet && !me) { - return { - ok: () => {}, - info: () => {}, - error: () => {}, - warn: () => {} - } - } - - // server implementation of wallet logger interface on client - const log = (level) => async (message, ctx = {}) => { - try { - let { invoiceId, withdrawalId, ...context } = ctx - - if (context.bolt11) { - // automatically populate context from bolt11 to avoid duplicating this code - context = { - ...context, - ...await logContextFromBolt11(context.bolt11) - } - } - - await models.walletLog.create({ - data: { - userId: wallet?.userId ?? me.id, - // system logs have no wallet - wallet: wallet?.type, - level, - message, - context, - invoiceId, - withdrawalId - } - }) - } catch (err) { - console.error('error creating wallet log:', err) - } - } - - return { - ok: (message, context) => log('SUCCESS')(message, context), - info: (message, context) => log('INFO')(message, context), - error: (message, context) => log('ERROR')(message, context), - warn: (message, context) => log('WARN')(message, context) - } -} - -async function upsertWallet ( - { wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { - if (!me) { - throw new GqlAuthenticationError() - } - assertApiKeyNotPermitted({ me }) - - if (testCreateInvoice) { - try { - const pr = await testCreateInvoice(data) - if (!pr || typeof pr !== 'string' || !pr.startsWith('lnbc')) { - throw new GqlInputError('not a valid payment request') - } - } catch (err) { - const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) - logger.error(message) - throw new GqlInputError(message) - } - } - - const { id, enabled, priority, ...recvConfig } = data - - const txs = [] - - if (id) { - const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } }) - - // createMany is the set difference of the new - old - // deleteMany is the set difference of the old - new - // updateMany is the intersection of the old and new - const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key])) - const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key])) - .map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) })) - - txs.push( - models.wallet.update({ - where: { id: Number(id), userId: me.id }, - data: { - enabled, - priority, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 - ? { - [wallet.field]: { - upsert: { - create: recvConfig, - update: recvConfig - } - } - } - : {}), - ...(vaultEntries - ? { - vaultEntries: { - deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({ - userId: me.id, key - })), - create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({ - key, iv, value, userId: me.id - })), - update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({ - where: { userId_key: { userId: me.id, key } }, - data: { value, iv } - })) - } - } - : {}) - - }, - include: { - vaultEntries: true - } - }) - ) - } else { - txs.push( - models.wallet.create({ - include: { - vaultEntries: true - }, - data: { - enabled, - priority, - userId: me.id, - type: wallet.type, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}), - ...(vaultEntries - ? { - vaultEntries: { - createMany: { - data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id })) - } - } - } - : {}) - } - }) - ) - } - - if (settings) { - txs.push( - models.user.update({ - where: { id: me.id }, - data: settings - }) - ) - } - - if (canReceive({ def: walletDef, config: recvConfig })) { - txs.push( - models.walletLog.createMany({ - data: { - userId: me.id, - wallet: wallet.type, - level: 'SUCCESS', - message: id ? 'details for receiving updated' : 'details for receiving saved' - } - }), - models.walletLog.create({ - data: { - userId: me.id, - wallet: wallet.type, - level: enabled ? 'SUCCESS' : 'INFO', - message: enabled ? 'receiving enabled' : 'receiving disabled' - } - }) - ) - } - - const [upsertedWallet] = await models.$transaction(txs) - return upsertedWallet -} - -export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) { +export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) { assertApiKeyNotPermitted({ me }) await validateSchema(withdrawlSchema, { invoice, maxFee }) await assertGofacYourself({ models, headers }) @@ -984,7 +597,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model throw new GqlInputError('SN cannot pay an invoice that SN is proxying') } - return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd }) + return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd }) } async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e42..29ed7dda 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -18,7 +18,6 @@ import admin from './admin' import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' -import vault from './vault' const common = gql` type Query { @@ -39,4 +38,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction] diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1e715591..8c768b3a 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -124,9 +124,6 @@ export default gql` zapUndos: Int wildWestMode: Boolean! withdrawMaxFeeDefault: Int! - proxyReceive: Boolean - receiveCreditsBelowSats: Int! - sendCreditsBelowSats: Int! } type AuthMethods { @@ -157,6 +154,7 @@ export default gql` upvotePopover: Boolean! hasInvites: Boolean! apiKeyEnabled: Boolean! + showPassphrase: Boolean! """ mirrors SettingsInput @@ -203,14 +201,8 @@ export default gql` wildWestMode: Boolean! withdrawMaxFeeDefault: Int! autoWithdrawThreshold: Int - autoWithdrawMaxFeePercent: Float - autoWithdrawMaxFeeTotal: Int vaultKeyHash: String walletsUpdatedAt: Date - proxyReceive: Boolean - directReceive: Boolean @deprecated - receiveCreditsBelowSats: Int! - sendCreditsBelowSats: Int! } type UserOptional { diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js deleted file mode 100644 index 3e2860a3..00000000 --- a/api/typeDefs/vault.js +++ /dev/null @@ -1,28 +0,0 @@ -import { gql } from 'graphql-tag' - -export default gql` - type VaultEntry { - id: ID! - key: String! - iv: String! - value: String! - createdAt: Date! - updatedAt: Date! - } - - input VaultEntryInput { - key: String! - iv: String! - value: String! - walletId: ID - } - - extend type Query { - getVaultEntries: [VaultEntry!]! - } - - extend type Mutation { - clearVault: Boolean - updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean - } -` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index b003ba42..885c6b49 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,66 +1,6 @@ import { gql } from 'graphql-tag' -import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql' -import { isServerField } from '@/wallets/common' -import walletDefs from '@/wallets/server' -function injectTypeDefs (typeDefs) { - const injected = [rawTypeDefs(), mutationTypeDefs()] - return `${typeDefs}\n\n${injected.join('\n\n')}\n` -} - -function mutationTypeDefs () { - console.group('injected GraphQL mutations:') - - const typeDefs = walletDefs.map((w) => { - let args = 'id: ID, ' - const serverFields = w.fields - .filter(isServerField) - .map(fieldToGqlArgOptional) - if (serverFields.length > 0) args += serverFields.join(', ') + ',' - args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean' - const resolverName = generateResolverName(w.walletField) - const typeDef = `${resolverName}(${args}): Wallet` - console.log(typeDef) - return typeDef - }) - - console.groupEnd() - - return `extend type Mutation {\n${typeDefs.join('\n')}\n}` -} - -function rawTypeDefs () { - console.group('injected GraphQL type defs:') - - const typeDefs = walletDefs.map((w) => { - let args = w.fields - .filter(isServerField) - .map(fieldToGqlArg) - .map(s => ' ' + s) - .join('\n') - if (!args) { - // add a placeholder arg so the type is not empty - args = ' _empty: Boolean' - } - const typeDefName = generateTypeDefName(w.walletType) - const typeDef = `type ${typeDefName} {\n${args}\n}` - console.log(typeDef) - return typeDef - }) - - let union = 'union WalletDetails = ' - union += walletDefs.map((w) => { - const typeDefName = generateTypeDefName(w.walletType) - return typeDefName - }).join(' | ') - console.log(union) - - console.groupEnd() - - return typeDefs.join('\n\n') + union -} - -const typeDefs = ` +const typeDefs = gql` extend type Query { invoice(id: ID!): Invoice! withdrawl(id: ID!): Withdrawl! @@ -68,8 +8,10 @@ const typeDefs = ` numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History - wallets: [Wallet!]! - walletLogs(type: String, from: String, to: String, cursor: String): WalletLog! + wallets: [WalletOrTemplate!]! + wallet(id: ID, name: String): WalletOrTemplate + walletSettings: WalletSettings! + walletLogs(protocolId: Int, cursor: String): WalletLogs! failedInvoices: [Invoice!]! } @@ -79,9 +21,30 @@ const typeDefs = ` cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice! dropBolt11(hash: String!): Boolean removeWallet(id: ID!): Boolean - deleteWalletLogs(wallet: String): Boolean - setWalletPriority(id: ID!, priority: Int!): Boolean + deleteWalletLogs(protocolId: Int): Boolean + setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean buyCredits(credits: Int!): BuyCreditsPaidAction! + + upsertWalletSendLNbits(walletId: ID, templateName: ID, enabled: Boolean!, url: String!, apiKey: VaultEntryInput!): WalletSendLNbits! + upsertWalletRecvLNbits(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!, apiKey: String!): WalletRecvLNbits! + upsertWalletSendPhoenixd(walletId: ID, templateName: ID, enabled: Boolean!, url: String!, apiKey: VaultEntryInput!): WalletSendPhoenixd! + upsertWalletRecvPhoenixd(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!, apiKey: String!): WalletRecvPhoenixd! + upsertWalletSendBlink(walletId: ID, templateName: ID, enabled: Boolean!, currency: VaultEntryInput!, apiKey: VaultEntryInput!): WalletSendBlink! + upsertWalletRecvBlink(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, currency: String!, apiKey: String!): WalletRecvBlink! + upsertWalletRecvLightningAddress(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, address: String!): WalletRecvLightningAddress! + upsertWalletSendNWC(walletId: ID, templateName: ID, enabled: Boolean!, url: VaultEntryInput!): WalletSendNWC! + upsertWalletRecvNWC(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!): WalletRecvNWC! + upsertWalletRecvCLNRest(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, rune: String!, cert: String): WalletRecvCLNRest! + upsertWalletRecvLNDGRPC(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, macaroon: String!, cert: String): WalletRecvLNDGRPC! + upsertWalletSendLNC(walletId: ID, templateName: ID, enabled: Boolean!, pairingPhrase: VaultEntryInput!, localKey: VaultEntryInput!, remoteKey: VaultEntryInput!, serverHost: VaultEntryInput!): WalletSendLNC! + upsertWalletSendWebLN(walletId: ID, templateName: ID, enabled: Boolean!): WalletSendWebLN! + removeWalletProtocol(id: ID!): Boolean + updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean + updateKeyHash(keyHash: String!): Boolean + resetWallets(newKeyHash: String!): Boolean + disablePassphraseExport: Boolean + setWalletSettings(settings: WalletSettingsInput!): Boolean + addWalletLog(protocolId: Int!, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean } type BuyCreditsResult { @@ -92,15 +55,155 @@ const typeDefs = ` id: ID! } + union WalletOrTemplate = Wallet | WalletTemplate + + enum WalletStatus { + OK + WARNING + ERROR + DISABLED + } + type Wallet { id: ID! - createdAt: Date! - updatedAt: Date! - type: String! - enabled: Boolean! + name: String! priority: Int! - wallet: WalletDetails! - vaultEntries: [VaultEntry!]! + template: WalletTemplate! + protocols: [WalletProtocol!]! + send: WalletStatus! + receive: WalletStatus! + } + + type WalletTemplate { + name: ID! + protocols: [WalletProtocolTemplate!]! + send: WalletStatus! + receive: WalletStatus! + } + + type WalletProtocol { + id: ID! + name: String! + send: Boolean! + enabled: Boolean! + config: WalletProtocolConfig! + status: WalletStatus! + } + + type WalletProtocolTemplate { + id: ID! + name: String! + send: Boolean! + } + + union WalletProtocolConfig = + | WalletSendNWC + | WalletSendLNbits + | WalletSendPhoenixd + | WalletSendBlink + | WalletSendWebLN + | WalletSendLNC + | WalletRecvNWC + | WalletRecvLNbits + | WalletRecvPhoenixd + | WalletRecvBlink + | WalletRecvLightningAddress + | WalletRecvCLNRest + | WalletRecvLNDGRPC + + type WalletSettings { + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! + autoWithdrawThreshold: Int + autoWithdrawMaxFeePercent: Float + autoWithdrawMaxFeeTotal: Int + proxyReceive: Boolean! + } + + input WalletSettingsInput { + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! + autoWithdrawThreshold: Int! + autoWithdrawMaxFeePercent: Float! + autoWithdrawMaxFeeTotal: Int! + proxyReceive: Boolean! + } + + type WalletSendNWC { + id: ID! + url: VaultEntry! + } + + type WalletSendLNbits { + id: ID! + url: String! + apiKey: VaultEntry! + } + + type WalletSendPhoenixd { + id: ID! + url: String! + apiKey: VaultEntry! + } + + type WalletSendBlink { + id: ID! + currency: VaultEntry! + apiKey: VaultEntry! + } + + type WalletSendWebLN { + id: ID! + } + + type WalletSendLNC { + id: ID! + pairingPhrase: VaultEntry! + localKey: VaultEntry! + remoteKey: VaultEntry! + serverHost: VaultEntry! + } + + type WalletRecvNWC { + id: ID! + url: String! + } + + type WalletRecvLNbits { + id: ID! + url: String! + apiKey: String! + } + + type WalletRecvPhoenixd { + id: ID! + url: String! + apiKey: String! + } + + type WalletRecvBlink { + id: ID! + currency: String! + apiKey: String! + } + + type WalletRecvLightningAddress { + id: ID! + address: String! + } + + type WalletRecvCLNRest { + id: ID! + socket: String! + rune: String! + cert: String + } + + type WalletRecvLNDGRPC { + id: ID! + socket: String! + macaroon: String! + cert: String } input AutowithdrawSettings { @@ -109,6 +212,22 @@ const typeDefs = ` autoWithdrawMaxFeeTotal: Int! } + input WalletEncryptionUpdate { + id: ID! + protocols: [WalletEncryptionUpdateProtocol!]! + } + + input WalletEncryptionUpdateProtocol { + name: String! + send: Boolean! + config: JSONObject! + } + + input WalletPriorityUpdate { + id: ID! + priority: Int! + } + type Invoice implements InvoiceOrDirect { id: ID! createdAt: Date! @@ -183,7 +302,7 @@ const typeDefs = ` cursor: String } - type WalletLog { + type WalletLogs { entries: [WalletLogEntry!]! cursor: String } @@ -191,11 +310,25 @@ const typeDefs = ` type WalletLogEntry { id: ID! createdAt: Date! - wallet: ID + wallet: Wallet + protocol: WalletProtocol level: String! message: String! context: JSONObject } -` -export default gql`${injectTypeDefs(typeDefs)}` + type VaultEntry { + id: ID! + iv: String! + value: String! + createdAt: Date! + updatedAt: Date! + } + + input VaultEntryInput { + iv: String! + value: String! + keyHash: String! + } +` +export default typeDefs diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js deleted file mode 100644 index d0d837b7..00000000 --- a/components/autowithdraw-shared.js +++ /dev/null @@ -1,76 +0,0 @@ -import { InputGroup } from 'react-bootstrap' -import { Input } from './form' -import { useMe } from './me' -import { useEffect, useState } from 'react' -import { isNumber } from '@/lib/format' -import Link from 'next/link' - -function autoWithdrawThreshold ({ me }) { - return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 -} - -export function autowithdrawInitial ({ me }) { - return { - autoWithdrawThreshold: autoWithdrawThreshold({ me }), - autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1, - autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1 - } -} - -export function AutowithdrawSettings () { - const { me } = useMe() - const threshold = autoWithdrawThreshold({ me }) - - const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1)) - - useEffect(() => { - setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) - }, [autoWithdrawThreshold]) - - return ( - <> -
-
-

desired balance

-
applies globally to all autowithdraw methods
- { - const value = e.target.value - setSendThreshold(Math.max(Math.floor(value / 10), 1)) - }} - hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined} - append={sats} - required - /> -

network fees

-
- we'll use whichever setting is higher during{' '} - pathfinding - -
- %} - required - /> - sats} - required - /> -
-
- - - ) -} diff --git a/components/banners.js b/components/banners.js index c4edbf0f..8f1523ab 100644 --- a/components/banners.js +++ b/components/banners.js @@ -5,7 +5,6 @@ import { useMe } from '@/components/me' import { useMutation } from '@apollo/client' import { WELCOME_BANNER_MUTATION } from '@/fragments/users' import { useToast } from '@/components/toast' -import Link from 'next/link' export function WelcomeBanner ({ Banner }) { const { me } = useMe() @@ -100,22 +99,6 @@ export function MadnessBanner ({ handleClose }) { ) } -export function WalletSecurityBanner ({ isActive }) { - return ( - - - Gunslingin' Safety Tips - -

- Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet. -

-

- Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, enable device sync in your settings. -

-
- ) -} - export function AuthBanner () { return ( diff --git a/components/form.js b/components/form.js index 0a4c70ea..44c1c5e1 100644 --- a/components/form.js +++ b/components/form.js @@ -34,12 +34,9 @@ import Info from './info' import { useMe } from './me' import classNames from 'classnames' import Clipboard from '@/svgs/clipboard-line.svg' -import QrIcon from '@/svgs/qr-code-line.svg' import QrScanIcon from '@/svgs/qr-scan-line.svg' import { useShowModal } from './modal' -import { QRCodeSVG } from 'qrcode.react' import dynamic from 'next/dynamic' -import { qrImageSettings } from './qr' import { useIsClient } from './use-client' import PageLoading from './page-loading' @@ -78,7 +75,7 @@ export function SubmitButton ({ ) } -function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) @@ -1333,33 +1330,6 @@ function PasswordHider ({ onClick, showPass }) { ) } -function QrPassword ({ value }) { - const showModal = useShowModal() - const toaster = useToast() - - const showQr = useCallback(() => { - showModal(close => ( -
-

Import this passphrase into another device by navigating to device sync settings and scanning this QR code

-
- -
-
- )) - }, [toaster, value, showModal]) - - return ( - <> - - - - - ) -} - function PasswordScanner ({ onScan, text }) { const showModal = useShowModal() const toaster = useToast() @@ -1422,12 +1392,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini {copy && ( )} - {qr && (readOnly - ? - : helpers.setValue(v)} - />)} + {qr && ( + helpers.setValue(v)} + /> + )} {append} ) diff --git a/components/invoice.js b/components/invoice.js index bedf285c..cdc984ed 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown' import PayerData from './payer-data' import Bolt11Info from './bolt11-info' import { useQuery } from '@apollo/client' -import { INVOICE } from '@/fragments/wallet' +import { INVOICE } from '@/fragments/invoice' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' -import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors' +import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors' import ItemJob from './item-job' import Item from './item' import { CommentFlat } from './comment' diff --git a/components/item-act.js b/components/item-act.js index 27172d3b..87189b4c 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -12,7 +12,7 @@ import { usePaidMutation } from './use-paid-mutation' import { ACT_MUTATION } from '@/fragments/paidAction' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' -import { useSendWallets } from '@/wallets/index' +import { useHasSendWallet } from '@/wallets/client/hooks' import { useAnimation } from '@/components/animation' const defaultTips = [100, 1000, 10_000, 100_000] @@ -88,7 +88,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) { const inputRef = useRef(null) const { me } = useMe() - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const [oValue, setOValue] = useState() useEffect(() => { @@ -116,7 +116,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a if (!me) setItemMeAnonSats({ id: item.id, amount }) } - const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount) + const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount) if (closeImmediately) { onPaid() } @@ -126,7 +126,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a id: item.id, sats: Number(amount), act, - hasSendWallet: wallets.length > 0 + hasSendWallet }, optimisticResponse: me ? { @@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a }) if (error) throw error addCustomTip(Number(amount)) - }, [me, actor, wallets.length, act, item.id, onClose, abortSignal, animate]) + }, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate]) return act === 'BOOST' ? {children} @@ -263,13 +263,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { // because the mutation name we use varies, // we need to extract the result/invoice from the response const getPaidActionResult = data => Object.values(data)[0] - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const [act] = usePaidMutation(query, { waitFor: inv => // if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure // we don't prematurely consider the payment as successful (important for receiver fallbacks) - wallets.length > 0 + hasSendWallet ? inv?.actionState === 'PAID' : inv?.satsReceived > 0, ...options, @@ -298,7 +298,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { } export function useZap () { - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const act = useAct() const animate = useAnimation() const toaster = useToast() @@ -309,14 +309,14 @@ export function useZap () { // add current sats to next tip since idempotent zaps use desired total zap not difference const sats = nextTip(meSats, { ...me?.privates }) - const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 } + const variables = { id: item.id, sats, act: 'TIP', hasSendWallet } const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } try { await abortSignal.pause({ me, amount: sats }) animate() // batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request - const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } }) + const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } }) if (error) throw error } catch (error) { if (error instanceof ActCanceledError) { @@ -327,7 +327,7 @@ export function useZap () { // but right now this toast is noisy for optimistic zaps console.error(error) } - }, [act, toaster, animate, wallets]) + }, [act, toaster, animate, hasSendWallet]) } export class ActCanceledError extends Error { diff --git a/components/log-message.js b/components/log-message.js deleted file mode 100644 index 73f76263..00000000 --- a/components/log-message.js +++ /dev/null @@ -1,62 +0,0 @@ -import { timeSince } from '@/lib/time' -import styles from '@/styles/log.module.css' -import { Fragment, useState } from 'react' - -export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) { - const [show, setShow] = useState(false) - - let className - switch (level.toLowerCase()) { - case 'ok': - case 'success': - level = 'ok' - className = 'text-success'; break - case 'error': - className = 'text-danger'; break - case 'warn': - className = 'text-warning'; break - default: - className = 'text-info' - } - - const filtered = context - ? Object.keys(context) - .filter(key => !['send', 'recv', 'status'].includes(key)) - .reduce((obj, key) => { - obj[key] = context[key] - return obj - }, {}) - : {} - - const hasContext = context && Object.keys(filtered).length > 0 - - const handleClick = () => { - if (hasContext) { setShow(show => !show) } - } - - const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' } - const indicator = hasContext ? (show ? '-' : '+') : <> - - return ( - <> - - {timeSince(new Date(ts))} - {showWallet ? [{wallet}] : } - {level} - {message} - {indicator} - - {show && hasContext && Object.entries(filtered) - .map(([key, value], i) => { - const last = i === Object.keys(filtered).length - 1 - return ( - - - {key} - {value} - - ) - })} - - ) -} diff --git a/components/modal.js b/components/modal.js index f5039ebc..151674cf 100644 --- a/components/modal.js +++ b/components/modal.js @@ -4,6 +4,12 @@ import BackArrow from '@/svgs/arrow-left-line.svg' import { useRouter } from 'next/router' import ActionDropdown from './action-dropdown' +export class ModalClosedError extends Error { + constructor () { + super('modal closed') + } +} + export const ShowModalContext = createContext(() => null) export function ShowModalProvider ({ children }) { diff --git a/components/nav/common.js b/components/nav/common.js index 3f9bbb61..80cf1335 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -19,8 +19,8 @@ import SearchIcon from '../../svgs/search-line.svg' import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' -import { useWallets } from '@/wallets/index' -import { useWalletIndicator } from '@/wallets/indicator' +// import { useWallets } from '@/wallets/client/hooks' +import { useWalletIndicator } from '@/wallets/client/hooks' import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' import { numWithUnits } from '@/lib/format' @@ -293,7 +293,7 @@ export default function LoginButton () { function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() - const { removeLocalWallets } = useWallets() + // const { removeLocalWallets } = useWallets() const router = useRouter() return ( @@ -324,8 +324,6 @@ function LogoutObstacle ({ onClose }) { await togglePushSubscription().catch(console.error) } - removeLocalWallets() - await signOut({ callbackUrl: '/' }) }} > diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index 219e2917..09e60219 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -7,7 +7,7 @@ import AnonIcon from '@/svgs/spy-fill.svg' import styles from './footer.module.css' import canvasStyles from './offcanvas.module.css' import classNames from 'classnames' -import { useWalletIndicator } from '@/wallets/indicator' +import { useWalletIndicator } from '@/wallets/client/hooks' export default function OffCanvas ({ me, dropNavKey }) { const [show, setShow] = useState(false) diff --git a/components/pay-bounty.js b/components/pay-bounty.js index d960c316..b4d079e5 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -8,7 +8,7 @@ import { useRoot } from './root' import { ActCanceledError, useAct } from './item-act' import { useAnimation } from '@/components/animation' import { useToast } from './toast' -import { useSendWallets } from '@/wallets/index' +import { useHasSendWallet } from '@/wallets/client/hooks' import { Form, SubmitButton } from './form' export const payBountyCacheMods = { @@ -50,9 +50,9 @@ export default function PayBounty ({ children, item }) { const root = useRoot() const animate = useAnimation() const toaster = useToast() - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() - const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 } + const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet } const act = useAct({ variables, optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index 0afeeeeb..6a652b8a 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -1,300 +1,165 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useMe } from '@/components/me' +import { useCallback, useMemo } from 'react' -export function getDbName (userId, name) { - return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}` -} +const VERSION = 2 -const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true } -const DEFAULT_INDICES = [] -const DEFAULT_VERSION = 1 +export function useIndexedDB (dbName) { + const { me } = useMe() + if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage' -function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) { - const [db, setDb] = useState(null) - const [error, setError] = useState(null) - const [notSupported, setNotSupported] = useState(false) - const operationQueue = useRef([]) - - const handleError = useCallback((error) => { - console.error('IndexedDB error:', error) - setError(error) - }, []) - - const processQueue = useCallback((db) => { - if (!db) return + const set = useCallback(async (storeName, key, value) => { + const db = await _open(dbName, VERSION) try { - // try to run a noop to see if the db is ready - db.transaction(storeName) - while (operationQueue.current.length > 0) { - const operation = operationQueue.current.shift() - // if the db is the same as the one we're processing, run the operation - // else, we'll just clear the operation queue - // XXX this is a consquence of using a ref to store the queue and should be fixed - if (dbName === db.name) { - operation(db) - } - } - } catch (error) { - handleError(error) + return await _set(db, storeName, key, value) + } finally { + db.close() } - }, [dbName, storeName, handleError, operationQueue]) + }, [dbName]) - useEffect(() => { - let isMounted = true + const get = useCallback(async (storeName, key) => { + const db = await _open(dbName, VERSION) + + try { + return await _get(db, storeName, key) + } finally { + db.close() + } + }, [dbName]) + + const deleteDb = useCallback(async () => { + return await _delete(dbName) + }, [dbName]) + + const open = useCallback(async () => { + return await _open(dbName, VERSION) + }, [dbName]) + + return useMemo(() => ({ set, get, deleteDb, open }), [set, get, deleteDb, open]) +} + +async function _open (dbName, version = 1) { + return await new Promise((resolve, reject) => { + if (typeof window.indexedDB === 'undefined') { + return reject(new IndexedDBOpenError('IndexedDB unavailable')) + } + + const request = window.indexedDB.open(dbName, version) + + request.onupgradeneeded = (event) => { + try { + const db = event.target.result + if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault') + if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs') + } catch (error) { + reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`)) + } + } + + request.onerror = (event) => { + reject(new IndexedDBOpenError(request.error?.message)) + } + + request.onsuccess = (event) => { + const db = request.result + resolve(db) + } + }) +} + +async function _set (db, storeName, key, value) { + return await new Promise((resolve, reject) => { let request try { - if (!window.indexedDB) { - console.log('IndexedDB is not supported') - setNotSupported(true) - return - } - - request = window.indexedDB.open(dbName, version) - - request.onerror = (event) => { - handleError(new Error('Error opening database')) - } - - request.onsuccess = (event) => { - if (isMounted) { - const database = event.target.result - database.onversionchange = () => { - database.close() - setDb(null) - handleError(new Error('Database is outdated, please reload the page')) - } - setDb(database) - processQueue(database) - } - } - - request.onupgradeneeded = (event) => { - const database = event.target.result - try { - const store = database.createObjectStore(storeName, options) - - indices.forEach(index => { - store.createIndex(index.name, index.keyPath, index.options) - }) - } catch (error) { - handleError(new Error('Error upgrading database: ' + error.message)) - } - } + request = db + .transaction(storeName, 'readwrite') + .objectStore(storeName) + .put(value, key) } catch (error) { - handleError(new Error('Error opening database: ' + error.message)) + return reject(new IndexedDBSetError(error?.message)) } - return () => { - isMounted = false - if (db) { - db.close() - } - } - }, [dbName, storeName, version, indices, options, handleError, processQueue]) - - const queueOperation = useCallback((operation) => { - if (notSupported) { - return Promise.reject(new Error('IndexedDB is not supported')) - } - if (error) { - return Promise.reject(new Error('Database error: ' + error.message)) + request.onerror = (event) => { + reject(new IndexedDBSetError(event.target?.error?.message)) } - return new Promise((resolve, reject) => { - const wrappedOperation = (db) => { - try { - const result = operation(db) - resolve(result) - } catch (error) { - reject(error) - } - } - - operationQueue.current.push(wrappedOperation) - processQueue(db) - }) - }, [processQueue, db, notSupported, error]) - - const add = useCallback((value) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.add(value) - - request.onerror = () => reject(new Error('Error adding data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const get = useCallback((key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const request = store.get(key) - - request.onerror = () => reject(new Error('Error getting data')) - request.onsuccess = () => resolve(request.result ? request.result : undefined) - }) - }) - }, [queueOperation, storeName]) - - const getAll = useCallback(() => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const request = store.getAll() - - request.onerror = () => reject(new Error('Error getting all data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const set = useCallback((key, value) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.put(value, key) - - request.onerror = () => reject(new Error('Error setting data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const remove = useCallback((key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.delete(key) - - request.onerror = () => reject(new Error('Error removing data')) - request.onsuccess = () => resolve() - }) - }) - }, [queueOperation, storeName]) - - const clear = useCallback((indexName = null, query = null) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - - if (!query) { - // Clear all data if no query is provided - const request = store.clear() - request.onerror = () => reject(new Error('Error clearing all data')) - request.onsuccess = () => resolve() - } else { - // Clear data based on the query - const index = indexName ? store.index(indexName) : store - const request = index.openCursor(query) - let deletedCount = 0 - - request.onerror = () => reject(new Error('Error clearing data based on query')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - const deleteRequest = cursor.delete() - deleteRequest.onerror = () => reject(new Error('Error deleting item')) - deleteRequest.onsuccess = () => { - deletedCount++ - cursor.continue() - } - } else { - resolve(deletedCount) - } - } - } - }) - }) - }, [queueOperation, storeName]) - - const getByIndex = useCallback((indexName, key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const index = store.index(indexName) - const request = index.get(key) - - request.onerror = () => reject(new Error('Error getting data by index')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const index = store.index(indexName) - const request = index.openCursor(query, direction) - const results = [] - - request.onerror = () => reject(new Error('Error getting data by index')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor && results.length < limit) { - results.push(cursor.value) - cursor.continue() - } else { - resolve(results) - } - } - }) - }) - }, [queueOperation, storeName]) - - const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const target = indexName ? store.index(indexName) : store - const request = target.openCursor(query, direction) - const results = [] - let skipped = 0 - let hasMore = false - - request.onerror = () => reject(new Error('Error getting page')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - if (skipped < (page - 1) * pageSize) { - skipped++ - cursor.continue() - } else if (results.length < pageSize) { - results.push(cursor.value) - cursor.continue() - } else { - hasMore = true - } - } - if (hasMore || !cursor) { - const countRequest = target.count() - countRequest.onsuccess = () => { - resolve({ - data: results, - total: countRequest.result, - hasMore - }) - } - countRequest.onerror = () => reject(new Error('Error counting items')) - } - } - }) - }) - }, [queueOperation, storeName]) - - return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported } + request.onsuccess = () => { + resolve(request.result) + } + }) } -export default useIndexedDB +async function _get (db, storeName, key) { + return await new Promise((resolve, reject) => { + let request + try { + request = db + .transaction(storeName) + .objectStore(storeName) + .get(key) + } catch (error) { + return reject(new IndexedDBGetError(error?.message)) + } + + request.onerror = (event) => { + reject(new IndexedDBGetError(event.target?.error?.message)) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) +} + +async function _delete (dbName) { + return await new Promise((resolve, reject) => { + if (typeof window.indexedDB === 'undefined') { + return reject(new IndexedDBOpenError('IndexedDB unavailable')) + } + + const request = window.indexedDB.deleteDatabase(dbName) + + request.onerror = (event) => { + reject(new IndexedDBDeleteError(event.target?.error?.message)) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) +} + +class IndexedDBError extends Error { + constructor (message) { + super(message) + this.name = 'IndexedDBError' + } +} + +class IndexedDBOpenError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBOpenError' + } +} + +class IndexedDBSetError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBSetError' + } +} + +class IndexedDBGetError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBGetError' + } +} + +class IndexedDBDeleteError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBDeleteError' + } +} diff --git a/components/use-invoice.js b/components/use-invoice.js index f59cd1db..a12d4d1f 100644 --- a/components/use-invoice.js +++ b/components/use-invoice.js @@ -1,8 +1,8 @@ import { useApolloClient, useMutation } from '@apollo/client' import { useCallback, useMemo } from 'react' -import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors' import { RETRY_PAID_ACTION } from '@/fragments/paidAction' -import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet' +import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice' export default function useInvoice () { const client = useApolloClient() diff --git a/components/use-item-submit.js b/components/use-item-submit.js index cd6eb867..b6ce3882 100644 --- a/components/use-item-submit.js +++ b/components/use-item-submit.js @@ -8,7 +8,7 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import gql from 'graphql-tag' import { USER_ID } from '@/lib/constants' import { useMe } from './me' -import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/prompt' +import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks' // this is intented to be compatible with upsert item mutations // so that it can be reused for all post types and comments and we don't have diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index f093ff45..110ac17b 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -2,9 +2,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import useQrPayment from '@/components/use-qr-payment' import useInvoice from '@/components/use-invoice' -import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors' import { GET_PAID_ACTION } from '@/fragments/paidAction' -import { useWalletPayment } from '@/wallets/payment' +import { useWalletPayment } from '@/wallets/client/hooks' /* this is just like useMutation with a few changes: diff --git a/components/use-qr-payment.js b/components/use-qr-payment.js index 8fbc9cf0..bfb29189 100644 --- a/components/use-qr-payment.js +++ b/components/use-qr-payment.js @@ -1,9 +1,9 @@ import { useCallback } from 'react' import Invoice from '@/components/invoice' -import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors' +import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors' import { useShowModal } from '@/components/modal' import useInvoice from '@/components/use-invoice' -import { sendPayment } from '@/wallets/webln/client' +import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln' export default function useQrPayment () { const invoice = useInvoice() @@ -19,7 +19,7 @@ export default function useQrPayment () { ) => { // if anon user and webln is available, try to pay with webln if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) { - sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) + weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) } return await new Promise((resolve, reject) => { let paid diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js deleted file mode 100644 index a3269f5a..00000000 --- a/components/vault/use-vault-configurator.js +++ /dev/null @@ -1,175 +0,0 @@ -import { useMutation, useQuery, makeVar, useReactiveVar } from '@apollo/client' -import { useMe } from '../me' -import { useToast } from '../toast' -import useIndexedDB, { getDbName } from '../use-indexeddb' -import { useCallback, useEffect, useMemo } from 'react' -import { E_VAULT_KEY_EXISTS } from '@/lib/error' -import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault' -import { toHex } from '@/lib/hex' -import { decryptValue, encryptValue } from './use-vault' - -const useImperativeQuery = (query) => { - const { refetch } = useQuery(query, { skip: true }) - - const imperativelyCallQuery = (variables) => { - return refetch(variables) - } - - return imperativelyCallQuery -} - -// reactive variable to store the vault key shared by all vaults -// so all vaults can react to changes in the vault key -// an alternative is to create a vault context which may be more idiomatic(?) -const keyReactiveVar = makeVar(null) - -export function useVaultConfigurator ({ onVaultKeySet, beforeDisconnectVault } = {}) { - const { me } = useMe() - const toaster = useToast() - const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id]) - const { set, get, remove } = useIndexedDB(idbConfig) - const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY) - const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES) - const key = useReactiveVar(keyReactiveVar) - - const disconnectVault = useCallback(async () => { - console.log('disconnecting vault') - beforeDisconnectVault?.() - await remove('key') - keyReactiveVar(null) - }, [remove, keyReactiveVar, beforeDisconnectVault]) - - useEffect(() => { - if (!me) return - - (async () => { - try { - const localVaultKey = await get('key') - if (localVaultKey?.hash && localVaultKey?.hash !== me?.privates?.vaultKeyHash) { - // If the hash stored in the server does not match the hash of the local key, - // we can tell that the key is outdated (reset by another device or other reasons) - // in this case we clear the local key and let the user re-enter the passphrase - console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', me?.privates?.vaultKeyHash) - await disconnectVault() - return - } - keyReactiveVar(localVaultKey) - } catch (e) { - console.error('error loading vault configuration', e) - // toaster?.danger('error loading vault configuration ' + e.message) - } - })() - }, [me?.privates?.vaultKeyHash, get, remove, keyReactiveVar, disconnectVault]) - - // clear vault: remove everything and reset the key - const [clearVault] = useMutation(CLEAR_VAULT, { - onCompleted: async () => { - try { - await remove('key') - keyReactiveVar(null) - } catch (e) { - toaster.danger('error clearing vault ' + e.message) - } - } - }) - - // initialize the vault and set a vault key - const setVaultKey = useCallback(async (passphrase) => { - try { - const oldKeyValue = await get('key') - const vaultKey = await deriveKey(me.id, passphrase) - const { data } = await getVaultEntries() - - const encrypt = async value => { - return await encryptValue(vaultKey.key, value) - } - - const entries = [] - if (oldKeyValue?.key) { - for (const { key, iv, value } of data.getVaultEntries) { - const plainValue = await decryptValue(oldKeyValue.key, { iv, value }) - entries.push({ key, ...await encrypt(plainValue) }) - } - } - - await updateVaultKey({ - variables: { entries, hash: vaultKey.hash }, - update: (cache, { data }) => { - cache.modify({ - id: `User:${me.id}`, - fields: { - privates: (existing) => ({ - ...existing, - vaultKeyHash: vaultKey.hash - }) - } - }) - }, - onError: (error) => { - const errorCode = error.graphQLErrors[0]?.extensions?.code - if (errorCode === E_VAULT_KEY_EXISTS) { - throw new Error('wrong passphrase') - } - toaster.danger(error.graphQLErrors[0].message) - } - }) - - await set('key', vaultKey) - onVaultKeySet?.(encrypt).catch(console.error) - keyReactiveVar(vaultKey) - } catch (e) { - console.error('error setting vault key', e) - toaster.danger(e.message) - } - }, [getVaultEntries, updateVaultKey, set, get, remove, onVaultKeySet, keyReactiveVar, me?.id]) - - return { key, setVaultKey, clearVault, disconnectVault } -} - -/** - * Derive a key to be used for the vault encryption - * @param {string | number} userId - the id of the user (used for salting) - * @param {string} passphrase - the passphrase to derive the key from - * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash - */ -async function deriveKey (userId, passphrase) { - const enc = new TextEncoder() - - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - enc.encode(passphrase), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ) - - const key = await window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: enc.encode(`stacker${userId}`), - // 600,000 iterations is recommended by OWASP - // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - iterations: 600_000, - hash: 'SHA-256' - }, - keyMaterial, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ) - - const rawKey = await window.crypto.subtle.exportKey('raw', key) - const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) - const unextractableKey = await window.crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ) - - return { - key: unextractableKey, - hash - } -} diff --git a/components/vault/use-vault.js b/components/vault/use-vault.js deleted file mode 100644 index d66f8048..00000000 --- a/components/vault/use-vault.js +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from 'react' -import { useVaultConfigurator } from './use-vault-configurator' -import { fromHex, toHex } from '@/lib/hex' - -export default function useVault () { - const { key } = useVaultConfigurator() - - const encrypt = useCallback(async (value) => { - if (!key) throw new Error('no vault key set') - return await encryptValue(key.key, value) - }, [key]) - - const decrypt = useCallback(async ({ iv, value }) => { - if (!key) throw new Error('no vault key set') - return await decryptValue(key.key, { iv, value }) - }, [key]) - - return { encrypt, decrypt, isActive: !!key?.key } -} - -/** - * Encrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for encryption - * @param {Object} value - the value to encrypt - * @returns {Promise} an object with iv and value properties, can be passed to decryptValue to get the original data back - */ -export async function encryptValue (sharedKey, value) { - // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure - // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm - // 12 bytes (96 bits) is the recommended IV size for AES-GCM - const iv = window.crypto.getRandomValues(new Uint8Array(12)) - const encoded = new TextEncoder().encode(JSON.stringify(value)) - const encrypted = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv - }, - sharedKey, - encoded - ) - return { - iv: toHex(iv.buffer), - value: toHex(encrypted) - } -} - -/** - * Decrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for decryption - * @param {Object} encryptedValue - the encrypted value as returned by encryptValue - * @returns {Promise} the original unencrypted data - */ -export async function decryptValue (sharedKey, { iv, value }) { - const decrypted = await window.crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: fromHex(iv) - }, - sharedKey, - fromHex(value) - ) - const decoded = new TextDecoder().decode(decrypted) - return JSON.parse(decoded) -} diff --git a/docker-compose.yml b/docker-compose.yml index ee09f0cc..76692875 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: env_file: *env_file volumes: - ./docker/db/seed.sql:/docker-entrypoint-initdb.d/seed.sql + - ./docker/db/wallet-seed.sql:/docker-entrypoint-initdb.d/wallet-seed.sql - db:/var/lib/postgresql/data labels: CONNECT: "localhost:5431" diff --git a/docker/db/wallet-seed.sql b/docker/db/wallet-seed.sql new file mode 100644 index 00000000..5a242f1c --- /dev/null +++ b/docker/db/wallet-seed.sql @@ -0,0 +1,186 @@ +/* + * This seed file inserts test wallets into the database to test wallet migrations. + * Only the wallets for which we could hardcode the configuration when this file was created will work to send or receive zaps. + * For example, NWC won't work for send or receive because it generates a random public key and secret every time the container is started for the first time. + */ + +-- device sync passphrase: media fit youth secret combine live cupboard response enable loyal kitchen angle +COPY public."users" ("id", "name", "vaultKeyHash") FROM stdin; +21001 test_wallet_v2 0feb0e0ed8684eaf37a995c4decac6d360125d40ff3fffe26239bb7ffd810853 +\. + +-- triggers will update the wallet JSON column in the Wallet table when we insert rows into the other wallet tables +COPY public."Wallet" ("id", "userId", "type", "enabled") FROM stdin; +1 21001 LIGHTNING_ADDRESS true +2 21001 NWC true +3 21001 WEBLN true +4 21001 LNBITS true +5 21001 CLN true +6 21001 BLINK true +7 21001 PHOENIXD true +8 21001 LND true +9 21001 LNC true +10 21001 LIGHTNING_ADDRESS true +11 21001 LIGHTNING_ADDRESS true +12 21001 LIGHTNING_ADDRESS true +13 21001 LIGHTNING_ADDRESS true +14 21001 LIGHTNING_ADDRESS true +15 21001 LIGHTNING_ADDRESS true +16 21001 LIGHTNING_ADDRESS true +17 21001 LIGHTNING_ADDRESS true +18 21001 LIGHTNING_ADDRESS true +19 21001 LIGHTNING_ADDRESS true +20 21001 LIGHTNING_ADDRESS true +21 21001 LIGHTNING_ADDRESS true +22 21001 LIGHTNING_ADDRESS true +23 21001 LIGHTNING_ADDRESS true +24 21001 LIGHTNING_ADDRESS true +25 21001 LIGHTNING_ADDRESS true +26 21001 LIGHTNING_ADDRESS true +27 21001 LIGHTNING_ADDRESS true +28 21001 NWC true +29 21001 NWC true +\. + +COPY public."WalletLightningAddress" ("id", "walletId", "address") FROM stdin; +1 1 john_doe@getalby.com +2 10 john_doe@rizful.com +3 11 john_doe@fountain.fm +4 12 john_doe@primal.net +5 13 john_doe@coinos.io +6 14 john_doe@speed.app +7 15 john_doe@tryspeed.com +8 16 john_doe@blink.sv +9 17 john_doe@zbd.gg +10 18 john_doe@strike.me +11 19 john_doe@minibits.cash +12 20 john_doe@npub.cash +13 21 john_doe@zeuspay.com +14 22 john_doe@fountain.fm +15 23 john_doe@lifpay.me +16 24 john_doe@rizful.com +17 25 john_doe@vlt.ge +19 26 john_doe@blixtwallet.com +20 27 john_doe@shockwallet.app +\. + +COPY public."WalletNWC" ("id", "walletId", "nwcUrlRecv") FROM stdin; +1 2 nostr+walletconnect://8682ce552a852b5e21c8fe1235823a6f175641538f4c5431ec559a75dfb7f73a?relay=wss://relay.getalby.com/v1&secret=99669866becdbfacef4e9c3f0d00f085ee1174bc973135f158bab769f37152b9&lud16=john_doe@getalby.com +2 28 nostr+walletconnect://8682ce552a852b5e21c8fe1235823a6f175641538f4c5431ec559a75dfb7f73a?relay=wss://relay-nwc.rizful.com&secret=99669866becdbfacef4e9c3f0d00f085ee1174bc973135f158bab769f37152b9 +\. + +COPY public."WalletLNbits" ("id", "walletId", "url", "invoiceKey") FROM stdin; +1 4 http://localhost:5001 5deed7cd634e4306bb5e696f4a03cdac +\. + +COPY public."WalletCLN" ("id", "walletId", "socket", "rune", "cert") FROM stdin; +1 5 cln:3010 Fz6ox9zLwTRfHSaKbxdr5SK4KyxAjL_UEniED6UEGRw9MCZtZXRob2Q9aW52b2ljZQ== LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlCY2pDQ0FSaWdBd0lCQWdJSkFOclN2UFovWTNLRU1Bb0dDQ3FHU000OUJBTUNNQll4RkRBU0JnTlZCQU1NDQpDMk5zYmlCU2IyOTBJRU5CTUNBWERUYzFNREV3TVRBd01EQXdNRm9ZRHpRd09UWXdNVEF4TURBd01EQXdXakFXDQpNUlF3RWdZRFZRUUREQXRqYkc0Z1VtOXZkQ0JEUVRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBDQpCQmptYUh1dWxjZ3dTR09ubExBSFlRbFBTUXdHWEROSld5ZnpWclY5aFRGYUJSZFFrMVl1Y3VqVFE5QXFybkVJDQpyRmR6MS9PeisyWFhENmdBMnhPbmIrNmpUVEJMTUJrR0ExVWRFUVFTTUJDQ0EyTnNib0lKYkc5allXeG9iM04wDQpNQjBHQTFVZERnUVdCQlNFY21OLzlyelMyaFI2RzdFSWdzWCs1MU4wQ2pBUEJnTlZIUk1CQWY4RUJUQURBUUgvDQpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJSENlUHZOU3Z5aUJZYXdxS2dRcXV3OUoyV1Z5SnhuMk1JWUlxejlTDQpRTDE4QWlFQWg4QlZEejhwWDdOc2xsOHNiMGJPMFJaNDljdnFRb2NDZ1ZhYnFKdVN1aWs9DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo= +\. + +COPY public."WalletBlink" ("id", "walletId", "apiKeyRecv", "currencyRecv") FROM stdin; +1 6 blink_IpGjMEmlLZrb3dx1RS5pcVm7Z6uKthb2UMg5bfGxcIV4Yae BTC +\. + +COPY public."WalletPhoenixd" ("id", "walletId", "url", "secondaryPassword") FROM stdin; +1 7 https://phoenixd.ekzy.is abb6dc487e788fcfa2bdaf587aa3f96a5ee4a3e8d7d8068131182c5919d974cd +\. + +COPY public."WalletLND" ("id", "walletId", "socket", "macaroon", "cert") FROM stdin; +1 8 lnd:10009 0201036c6e64022f030a1089912eeaa5f434e5265170565bcce0eb1201301a170a08696e766f6963657312047265616412057772697465000006200622e95cf2fe2d9a8976cbfb824809a9a5e8af861b659e396064f6de1dc79d04 LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNoVENDQWl1Z0F3SUJBZ0lSQUp5Zkg3cEdDZEhXTVJZVGo1d1pKSkF3Q2dZSUtvWkl6ajBFQXdJd09ERWYKTUIwR0ExVUVDaE1XYkc1a0lHRjFkRzluWlc1bGNtRjBaV1FnWTJWeWRERVZNQk1HQTFVRUF4TU1NR1V5T0dVNApPREkzTmpZd01CNFhEVEkxTURZd05URTRNak15TmxvWERUSTJNRGN6TVRFNE1qTXlObG93T0RFZk1CMEdBMVVFCkNoTVdiRzVrSUdGMWRHOW5aVzVsY21GMFpXUWdZMlZ5ZERFVk1CTUdBMVVFQXhNTU1HVXlPR1U0T0RJM05qWXcKTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFUE1lb2RYYTF2eXVxYXFaNklXbXgrNDVFdjBkUgpmQkY5SXZtMU5xQVNHUGlGT1JucEtxZVBVbm0xWmZlTUNETytwcGhQMHpGYVh4ZVBUU3BwaWMrYXlLT0NBUlF3CmdnRVFNQTRHQTFVZER3RUIvd1FFQXdJQ3BEQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBVEFQQmdOVkhSTUIKQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJUYkdKMlZDejN5WkFUd1JlUG1kckdvMnhkVmFqQ0J1QVlEVlIwUgpCSUd3TUlHdGdnd3daVEk0WlRnNE1qYzJOakNDQ1d4dlkyRnNhRzl6ZElJRGJHNWtnaFJvYjNOMExtUnZZMnRsCmNpNXBiblJsY201aGJJSStOelV5ZUdWNWIyeG1jSEJqTW5SbloybDZaSEZoYW1Kb2VXZHNjRzV4ZW10bGFtVmoKWW1oeGJIQnpNMjU0ZW5aMGMyZzNkMkZ0Y1dRdWIyNXBiMjZDQkhWdWFYaUNDblZ1YVhod1lXTnJaWFNDQjJKMQpabU52Ym02SEJIOEFBQUdIRUFBQUFBQUFBQUFBQUFBQUFBQUFBQUdIQkt3U0FBY3dDZ1lJS29aSXpqMEVBd0lEClNBQXdSUUlnY2pZZ2o5YVhpQjlOOVBmQUp0cWZRbStoYVdpbmZ0RTVXdkJ3Vis4NzgzTUNJUUNyaEx2Qys3RzQKN3NneENyYnlmLy9WdmxJN3BkakRlVFM0WGc4eHB2UmVEQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K +\. + +COPY public."VaultEntry" ("id", "key", "iv", "value", "userId", "walletId") FROM stdin; +1 nwcUrl 926622f7139d4b4506827549 b592906ea9c3ced5df077ca1ea0c787c2ea9173e39d062466b50723d8c0a568510ed42549215c6f6e15632601248f148fa18b87d7e0fe9ad667d10a12beb79e36d3cfcaa58ca65c78e8f41ee715c2b19ac8c638353cdc9098a784104eb9b1592b233d4327556de47d218f97991392105ff0868beada2667b308d544bf9e7199f056ecb8cd9c2f87a7f8f1eda7db7e80c880de12df4ce2dc5dcc16ec836d9a9f428f2c4e36f01bdbbd85084ecac308eefa1dbdfe89c2a321a3fafa1c35265a788a352c329f9e01d0988e47f05b8575fbcfb5814 21001 2 +2 adminKey 1b911294853df2e94e4d9823 438ff80df2e58e3f7988ba828fab1e7def3934b908a58de9f5f16bb36e1ecc65e1cab43c0ec658f65bdccb0a241bb5614697 21001 4 +3 apiKey b1f3500130b16bf4997fc370 3276869cf3d8c6d844e771688f8cd1a771279867165e4b1030b7ad90d537d5cfa0a6a82c2aaefe350db2a445b3b0c3b23a068edede3e78fe5957c1cfc6b5f1fd811786793c65aad90fe8ce 21001 6 +4 currency 8174fe225f0d53957a4daced b912faacc32725b9ec01128911eb3922822fb21bdc 21001 6 +5 primaryPassword 5e709c93ca34a135dad293d7 7509592ff463f886b7f7a621928a8ac0ca56b904d5835bf77ca5914a248fb70ad231f04aed893c0ef1dd7edd2d928d482d0eaeba7ae2381f3fd70ba25cb265de6091a11231a9cd3047f22ff2f838db046e67 21001 7 +6 pairingPhrase 0196718758dea2bff7c89741 bd6ff716ec5b20dc74f6507b87ed0923c8b27e33204ae44cee47d8c7f78dd5976cc446f2c9dd918f2916611a71e20e87fb9245cacfdb35bbc527a42c0df765e2f9589e56b5b253c0d39f8e954b 21001 9 +7 localKey 227bc46af405a40cb6697344 f4589aaca476b4905980b9dd834880926aff9e9c9217afa9b33152a74255698c9284015309ae19e10481843069a052dbe1a592e14db6aa13fce4e17fd9f5f2964720ba4686a4a45a1c72681248809e8de612 21001 9 +8 remoteKey e63b62d8af6a1227129e8c7b ce97d971cdcd58b34ec2e998c6ce6df72b8c21a9cd07e69db96c9491b3d9a051cf557d721552c5cc565a4d7f1bf1ad70b20048b90e1b244e77f0b635b5dbd798e0538f85d7008b29918a7e589dc1c2bde465c50c 21001 9 +9 serverHost a537d212e719810f6cbbc696 449c550ca2c24e761802087e5bc5637d0b4b231d9b771fbefbee6ed7c0a728862adc677cc283a373ec25f01003009f0c9cd18f884d08 21001 9 +\. + +COPY public."Invoice" ("id", "userId", "hash", "bolt11", "expiresAt", "msatsRequested") FROM stdin; +1 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a1 lnbc 2025-05-16 00:00:00 1000000 +2 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a2 lnbc 2025-05-16 00:00:00 2000000 +3 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a3 lnbc 2025-05-16 00:00:00 3000000 +4 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a4 lnbc 2025-05-16 00:00:00 4000000 +5 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a5 lnbc 2025-05-16 00:00:00 1000000 +6 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a6 lnbc 2025-05-16 00:00:00 2000000 +7 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a7 lnbc 2025-05-16 00:00:00 3000000 +8 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a8 lnbc 2025-05-16 00:00:00 4000000 +9 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a9 lnbc 2025-05-16 00:00:00 4000000 +10 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82aa lnbc 2025-05-16 00:00:00 4000000 +11 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ab lnbc 2025-05-16 00:00:00 4000000 +12 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ac lnbc 2025-05-16 00:00:00 4000000 +13 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ad lnbc 2025-05-16 00:00:00 4000000 +14 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ae lnbc 2025-05-16 00:00:00 4000000 +15 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82af lnbc 2025-05-16 00:00:00 4000000 +16 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb8210 lnbc 2025-05-16 00:00:00 4000000 +\. + +COPY public."Withdrawl" ("id", "userId", "walletId", "msatsPaying", "msatsFeePaying") FROM stdin; +1 21001 1 1000 0 +2 21001 2 1000 0 +3 21001 2 1000 0 +4 21001 5 1000 0 +5 21001 5 1000 0 +6 21001 6 1000 0 +7 21001 7 1000 0 +8 21001 7 1000 0 +9 21001 8 1000 0 +10 21001 10 1000 0 +11 21001 11 1000 0 +12 21001 27 1000 0 +13 21001 28 1000 0 +14 21001 29 1000 0 +15 21001 1 1000 0 +16 21001 4 1000 0 +17 21001 4 1000 0 +18 21001 7 1000 0 +19 21001 7 1000 0 +20 21001 8 1000 0 +\. + +COPY public."InvoiceForward" ("id", "walletId", "bolt11", "maxFeeMsats", "invoiceId", "withdrawlId") FROM stdin; +1 1 lnbc 1000 1 1 +2 2 lnbc 1000 2 2 +3 4 lnbc 1000 3 3 +4 4 lnbc 1000 4 4 +5 5 lnbc 1000 5 5 +6 6 lnbc 1000 6 6 +7 7 lnbc 1000 7 7 +8 8 lnbc 1000 8 8 +9 27 lnbc 1000 9 9 +10 28 lnbc 1000 10 10 +11 29 lnbc 1000 11 11 +12 4 lnbc 1000 12 12 +13 4 lnbc 1000 13 13 +14 5 lnbc 1000 14 14 +15 6 lnbc 1000 15 15 +16 7 lnbc 1000 16 16 +\. + +SELECT pg_catalog.setval('public."InvoiceForward_id_seq"', 16, true); + +COPY public."DirectPayment" ("id", "walletId", "senderId", "receiverId", "msats") FROM stdin; +1 1 21001 21001 1000 +2 2 21001 21001 1000 +3 4 21001 21001 1000 +4 5 21001 21001 1000 +5 6 21001 21001 1000 +6 7 21001 21001 1000 +7 8 21001 21001 1000 +8 16 21001 21001 1000 +9 27 21001 21001 1000 +10 28 21001 21001 1000 +11 29 21001 21001 1000 +12 7 21001 21001 1000 +13 7 21001 21001 1000 +14 5 21001 21001 1000 +15 5 21001 21001 1000 +16 4 21001 21001 1000 +\. + +SELECT pg_catalog.setval('public."DirectPayment_id_seq"', 16, true); diff --git a/fragments/wallet.js b/fragments/invoice.js similarity index 62% rename from fragments/wallet.js rename to fragments/invoice.js index ce1090a5..60bb43f4 100644 --- a/fragments/wallet.js +++ b/fragments/invoice.js @@ -1,6 +1,5 @@ import { gql } from '@apollo/client' import { ITEM_FULL_FIELDS } from './items' -import { VAULT_ENTRY_FIELDS } from './vault' export const INVOICE_FIELDS = gql` fragment InvoiceFields on Invoice { @@ -121,89 +120,6 @@ export const SEND_TO_LNADDR = gql` } }` -export const REMOVE_WALLET = -gql` -mutation removeWallet($id: ID!) { - removeWallet(id: $id) -} -` -// XXX [WALLET] this needs to be updated if another server wallet is added -export const WALLET_FIELDS = gql` - ${VAULT_ENTRY_FIELDS} - fragment WalletFields on Wallet { - id - priority - type - updatedAt - enabled - vaultEntries { - ...VaultEntryFields - } - wallet { - __typename - ... on WalletLightningAddress { - address - } - ... on WalletLnd { - socket - macaroon - cert - } - ... on WalletCln { - socket - rune - cert - } - ... on WalletLnbits { - url - invoiceKey - } - ... on WalletNwc { - nwcUrlRecv - } - ... on WalletPhoenixd { - url - secondaryPassword - } - ... on WalletBlink { - apiKeyRecv - currencyRecv - } - } - } -` - -export const WALLETS = gql` - ${WALLET_FIELDS} - query Wallets { - wallets { - ...WalletFields - } - } -` - -export const WALLET_LOGS = gql` - query WalletLogs($type: String, $from: String, $to: String, $cursor: String) { - walletLogs(type: $type, from: $from, to: $to, cursor: $cursor) { - cursor - entries { - id - createdAt - wallet - level - message - context - } - } - } -` - -export const SET_WALLET_PRIORITY = gql` - mutation SetWalletPriority($id: ID!, $priority: Int!) { - setWalletPriority(id: $id, priority: $priority) - } -` - export const CANCEL_INVOICE = gql` ${INVOICE_FIELDS} mutation cancelInvoice($hash: String!, $hmac: String, $userCancel: Boolean) { diff --git a/fragments/notifications.js b/fragments/notifications.js index 48a5c046..3460b00d 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -2,7 +2,7 @@ import { gql } from '@apollo/client' import { ITEM_FULL_FIELDS, POLL_FIELDS } from './items' import { INVITE_FIELDS } from './invites' import { SUB_FIELDS } from './subs' -import { INVOICE_FIELDS } from './wallet' +import { INVOICE_FIELDS } from './invoice' export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }` diff --git a/fragments/paidAction.js b/fragments/paidAction.js index 60ed16e2..f5ccdea3 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -1,7 +1,7 @@ import gql from 'graphql-tag' import { COMMENTS } from './comments' import { SUB_FULL_FIELDS } from './subs' -import { INVOICE_FIELDS } from './wallet' +import { INVOICE_FIELDS } from './invoice' const HASH_HMAC_INPUT_1 = '$hash: String, $hmac: String' const HASH_HMAC_INPUT_2 = 'hash: $hash, hmac: $hmac' diff --git a/fragments/users.js b/fragments/users.js index 86b594ba..66107639 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -25,9 +25,6 @@ ${STREAK_FIELDS} autoDropBolt11s noReferralLinks fiatCurrency - autoWithdrawMaxFeePercent - autoWithdrawMaxFeeTotal - autoWithdrawThreshold withdrawMaxFeeDefault satsFilter hideFromTopUsers @@ -52,7 +49,7 @@ ${STREAK_FIELDS} disableFreebies vaultKeyHash walletsUpdatedAt - proxyReceive + showPassphrase } optional { isContributor @@ -113,9 +110,6 @@ export const SETTINGS_FIELDS = gql` apiKey } apiKeyEnabled - proxyReceive - receiveCreditsBelowSats - sendCreditsBelowSats } }` diff --git a/fragments/vault.js b/fragments/vault.js deleted file mode 100644 index 10c3f31c..00000000 --- a/fragments/vault.js +++ /dev/null @@ -1,33 +0,0 @@ -import { gql } from '@apollo/client' - -export const VAULT_ENTRY_FIELDS = gql` - fragment VaultEntryFields on VaultEntry { - id - key - iv - value - createdAt - updatedAt - } -` - -export const GET_VAULT_ENTRIES = gql` - ${VAULT_ENTRY_FIELDS} - query GetVaultEntries { - getVaultEntries { - ...VaultEntryFields - } - } -` - -export const CLEAR_VAULT = gql` - mutation ClearVault { - clearVault - } -` - -export const UPDATE_VAULT_KEY = gql` - mutation updateVaultKey($entries: [VaultEntryInput!]!, $hash: String!) { - updateVaultKey(entries: $entries, hash: $hash) - } -` diff --git a/lib/apollo.js b/lib/apollo.js index 3739ba3f..4a60c2a5 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -95,6 +95,10 @@ function getClient (uri) { 'Reminder', 'ItemMention', 'Invoicification' + ], + WalletOrTemplate: [ + 'Wallet', + 'WalletTemplate' ] }, typePolicies: { @@ -290,6 +294,12 @@ function getClient (uri) { } } }, + walletLogs: { + keyArgs: ['protocolId'], + merge (existing, incoming) { + return incoming + } + }, failedInvoices: { keyArgs: [], merge (existing, incoming) { diff --git a/lib/url.js b/lib/url.js index c6871fc8..4c5baa17 100644 --- a/lib/url.js +++ b/lib/url.js @@ -187,32 +187,6 @@ export function stripTrailingSlash (uri) { return uri.endsWith('/') ? uri.slice(0, -1) : uri } -export function parseNwcUrl (walletConnectUrl) { - if (!walletConnectUrl) return {} - - walletConnectUrl = walletConnectUrl - .replace('nostrwalletconnect://', 'http://') - .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) - - // XXX There is a bug in parsing since we use the URL constructor for parsing: - // A wallet pubkey matching /^[0-9a-fA-F]{64}$/ might not be a valid hostname. - // Example: 11111111111 (10 1's) is a valid hostname (gets parsed as IPv4) but 111111111111 (11 1's) is not. - // See https://stackoverflow.com/questions/56804936/how-does-only-numbers-in-url-resolve-to-a-domain - // However, this seems to only get triggered if a wallet pubkey only contains digits so this is pretty improbable. - const url = new URL(walletConnectUrl) - const params = {} - params.walletPubkey = url.host - const secret = url.searchParams.get('secret') - const relayUrls = url.searchParams.getAll('relay') - if (secret) { - params.secret = secret - } - if (relayUrls) { - params.relayUrls = relayUrls - } - return params -} - export class ResponseAssertError extends Error { constructor (res, { message, method } = {}) { const urlPart = method ? `${method} ${res.url}` : res.url diff --git a/lib/validate.js b/lib/validate.js index e103c4b8..71e08300 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -12,7 +12,6 @@ import { numWithUnits } from './format' import { SUB } from '@/fragments/subs' import { NAME_QUERY } from '@/fragments/users' import { datePivot } from './time' -import bip39Words from './bip39-words' export async function validateSchema (schema, data, args) { try { @@ -52,12 +51,6 @@ export const lightningAddressValidator = process.env.NODE_ENV === 'development' 'address is no good') : string().email('address is no good') -export const externalLightningAddressValidator = lightningAddressValidator.test({ - name: 'address', - test: addr => !addr.toLowerCase().endsWith('@stacker.news'), - message: 'lightning address must be external' -}) - async function usernameExists (name, { client, models }) { if (!client && !models) { throw new Error('cannot check for user') @@ -480,6 +473,15 @@ export const settingsSchema = object().shape({ // exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720 }, [['tipRandomMax', 'tipRandomMin']]) +export const walletSettingsSchema = object({ + autoWithdrawThreshold: intValidator.min(0, 'must be greater or equal to 0').required('required'), + autoWithdrawMaxFeePercent: floatValidator.min(0, 'must be greater or equal to 0').required('required'), + autoWithdrawMaxFeeTotal: intValidator.min(0, 'must be greater or equal to 0').required('required'), + receiveCreditsBelowSats: intValidator.min(0, 'must be greater or equal to 0').required('required'), + sendCreditsBelowSats: intValidator.min(0, 'must be greater or equal to 0').required('required'), + proxyReceive: boolean().required('required') +}) + const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again' export const lastAuthRemovalSchema = object({ warning: string().matches(warningMessage, 'does not match').required('required') @@ -513,23 +515,3 @@ export const lud18PayerDataSchema = (k1) => object({ email: string().email('bad email address'), identifier: string() }) - -export const deviceSyncSchema = object().shape({ - passphrase: string().required('required') - .test(async (value, context) => { - const words = value ? value.trim().split(/[\s]+/) : [] - for (const w of words) { - try { - await string().oneOf(bip39Words).validate(w) - } catch { - return context.createError({ message: `'${w.slice(0, 10)}${w.length > 10 ? '...' : ''}' is not a valid pairing phrase word` }) - } - } - - if (words.length < 12) { - return context.createError({ message: 'needs at least 12 words' }) - } - - return true - }) -}) diff --git a/lib/yup.js b/lib/yup.js index 745cb5df..449d5aca 100644 --- a/lib/yup.js +++ b/lib/yup.js @@ -1,6 +1,4 @@ import { addMethod, string, mixed, array } from 'yup' -import { parseNwcUrl } from './url' -import { NOSTR_PUBKEY_HEX } from './nostr' import { ensureB64, HEX_REGEX } from './format' export * from 'yup' @@ -26,7 +24,7 @@ addMethod(string, 'or', orFunc) addMethod(string, 'hexOrBase64', function (schemas, msg = 'invalid hex or base64 encoding') { return this.test({ name: 'hex-or-base64', - message: 'invalid encoding', + message: msg, test: (val) => { if (typeof val === 'undefined') return true try { @@ -85,23 +83,6 @@ addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') { }) }) -addMethod(string, 'socket', function (schemas, msg = 'invalid socket') { - return this.test({ - name: 'socket', - message: msg, - test: value => { - try { - const url = new URL(`http://${value}`) - return url.hostname && url.port && !url.username && !url.password && - (!url.pathname || url.pathname === '/') && !url.search && !url.hash - } catch (e) { - return false - } - }, - exclusive: false - }) -}) - addMethod(string, 'https', function () { return this.test({ name: 'https', @@ -138,33 +119,6 @@ addMethod(string, 'hex', function (msg) { }) }) -addMethod(string, 'nwcUrl', function () { - return this.test({ - test: (nwcUrl, context) => { - if (!nwcUrl) return true - - // run validation in sequence to control order of errors - // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 - try { - string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl) - let relayUrls, walletPubkey, secret - try { - ({ relayUrls, walletPubkey, secret } = parseNwcUrl(nwcUrl)) - } catch { - // invalid URL error. handle as if pubkey validation failed to not confuse user. - throw new Error('pubkey must be 64 hex chars') - } - string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) - array().of(string().required('relay url required').trim().wss('relay must use wss://')).min(1, 'at least one relay required').validateSync(relayUrls) - string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - } - }) -}) - addMethod(array, 'equalto', function equals ( { required, optional }, message diff --git a/pages/_app.js b/pages/_app.js index 9c8ae2f1..f57e6729 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -19,8 +19,7 @@ import 'nprogress/nprogress.css' import { ChainFeeProvider } from '@/components/chain-fee.js' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' -import { WebLnProvider } from '@/wallets/webln/client' -import { WalletsProvider } from '@/wallets/index' +import WalletsProvider from '@/wallets/client/context' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -122,26 +121,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 97b30c92..6be8f860 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -8,7 +8,7 @@ import { formatMsats, toPositiveBigInt } from '@/lib/format' import assertGofacYourself from '@/api/resolvers/ofac' import performPaidAction from '@/api/paidAction' import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -16,7 +16,11 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } - const logger = walletLogger({ models, me: user }) + if (!amount || amount < 1000) { + return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) + } + + const logger = walletLogger({ models, userId: user.id }) logger.info(`${user.name}@stacker.news payment attempt`, { amount: formatMsats(amount), nostr, comment }) try { @@ -46,10 +50,6 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa descriptionHash = lnurlPayDescriptionHashForUser(username) } - if (!amount || amount < 1000) { - return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) - } - if (comment?.length > LNURLP_COMMENT_MAX_LENGTH) { return res.status(400).json({ status: 'ERROR', diff --git a/pages/directs/[id].js b/pages/directs/[id].js index c419388b..b7adcd1a 100644 --- a/pages/directs/[id].js +++ b/pages/directs/[id].js @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client' import { CenterLayout } from '@/components/layout' import { useRouter } from 'next/router' -import { DIRECT } from '@/fragments/wallet' +import { DIRECT } from '@/fragments/invoice' import { SSR, FAST_POLL_INTERVAL } from '@/lib/constants' import Bolt11Info from '@/components/bolt11-info' import { getGetServerSideProps } from '@/api/ssrApollo' diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index d18af628..8d555ae2 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -1,7 +1,7 @@ import Invoice from '@/components/invoice' import { CenterLayout } from '@/components/layout' import { useRouter } from 'next/router' -import { INVOICE_FULL } from '@/fragments/wallet' +import { INVOICE_FULL } from '@/fragments/invoice' import { getGetServerSideProps } from '@/api/ssrApollo' // force SSR to include CSP nonces diff --git a/pages/satistics/index.js b/pages/satistics/index.js index ef233185..d68eaad2 100644 --- a/pages/satistics/index.js +++ b/pages/satistics/index.js @@ -4,7 +4,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Nav from 'react-bootstrap/Nav' import Layout from '@/components/layout' import MoreFooter from '@/components/more-footer' -import { WALLET_HISTORY } from '@/fragments/wallet' +import { WALLET_HISTORY } from '@/fragments/invoice' import styles from '@/styles/satistics.module.css' import Moon from '@/svgs/moon-fill.svg' import Check from '@/svgs/check-double-line.svg' diff --git a/pages/settings/index.js b/pages/settings/index.js index 93cbc599..c42e12b4 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -76,11 +76,6 @@ export function SettingsHeader () { muted stackers - - - device sync - - ) @@ -155,16 +150,12 @@ export default function Settings ({ ssrData }) { hideBookmarks: settings?.hideBookmarks, hideWalletBalance: settings?.hideWalletBalance, hideIsContributor: settings?.hideIsContributor, - noReferralLinks: settings?.noReferralLinks, - proxyReceive: settings?.proxyReceive, - receiveCreditsBelowSats: settings?.receiveCreditsBelowSats, - sendCreditsBelowSats: settings?.sendCreditsBelowSats + noReferralLinks: settings?.noReferralLinks }} schema={settingsSchema} onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter, - receiveCreditsBelowSats, sendCreditsBelowSats, ...values }) => { if (nostrPubkey.length === 0) { @@ -190,8 +181,6 @@ export default function Settings ({ ssrData }) { withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault), satsFilter: Number(satsFilter), zapUndos: zapUndosEnabled ? Number(zapUndos) : null, - receiveCreditsBelowSats: Number(receiveCreditsBelowSats), - sendCreditsBelowSats: Number(sendCreditsBelowSats), nostrPubkey, nostrRelays: nostrRelaysFiltered, ...values @@ -336,35 +325,6 @@ export default function Settings ({ ssrData }) { name='noteCowboyHat' />
wallet
- sats} - /> - sats} - /> - enhance privacy of my lightning address - -
    -
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • -
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • -
  • This will incur in a 10% fee
  • -
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • -
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • -
-
- - } - name='proxyReceive' - groupClassName='mb-0' - /> hide invoice descriptions diff --git a/pages/settings/passphrase/index.js b/pages/settings/passphrase/index.js deleted file mode 100644 index 4d17afa0..00000000 --- a/pages/settings/passphrase/index.js +++ /dev/null @@ -1,211 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import Layout from '@/components/layout' -import { SettingsHeader } from '../index' -import { useVaultConfigurator } from '@/components/vault/use-vault-configurator' -import { useMe } from '@/components/me' -import { Button, InputGroup } from 'react-bootstrap' -import bip39Words from '@/lib/bip39-words' -import { Form, PasswordInput, SubmitButton } from '@/components/form' -import { deviceSyncSchema } from '@/lib/validate' -import RefreshIcon from '@/svgs/refresh-line.svg' -import { useCallback, useEffect, useState } from 'react' -import { useToast } from '@/components/toast' -import { useWallets } from '@/wallets/index' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function DeviceSync ({ ssrData }) { - const { me } = useMe() - const { onVaultKeySet, beforeDisconnectVault } = useWallets() - const { key, setVaultKey, clearVault, disconnectVault } = - useVaultConfigurator({ onVaultKeySet, beforeDisconnectVault }) - const [passphrase, setPassphrase] = useState() - - const setSeedPassphrase = useCallback(async (passphrase) => { - await setVaultKey(passphrase) - setPassphrase(passphrase) - }, [setVaultKey]) - - const enabled = !!me?.privates?.vaultKeyHash - const connected = !!key - - return ( - -
- - -

- Device sync uses end-to-end encryption to securely synchronize your data across devices. - - Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase. -

-
-
- { - (connected && passphrase && ) || - (connected && ) || - (enabled && ) || - - } -
-
-
- ) -} - -function Connect ({ passphrase }) { - return ( -
-

Connect other devices

-

- On your other devices, navigate to device sync settings and enter this exact passphrase. -

-

- Once you leave this page, this passphrase cannot be shown again. Connect all the devices you plan to use or write this passphrase down somewhere safe. -

- -
- ) -} - -function Connected ({ disconnectVault }) { - return ( -
-

Device sync is enabled!

-

- Sensitive data on this device is now securely synced between all connected devices. -

-

- Disconnect to prevent this device from syncing data or to reset your passphrase. -

-
-
- -
-
-
- ) -} - -function Enabled ({ setVaultKey, clearVault }) { - const toaster = useToast() - return ( -
-

Device sync is enabled

-

- This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it. -

-
{ - try { - await setVaultKey(passphrase) - } catch (e) { - console.error(e) - toaster.danger('error setting vault key') - } - }} - > - -
-
- - enable -
-
- -
- ) -} - -const generatePassphrase = (n = 12) => { - const rand = new Uint32Array(n) - window.crypto.getRandomValues(rand) - return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') -} - -function Setup ({ setSeedPassphrase }) { - const [passphrase, setPassphrase] = useState() - const toaster = useToast() - const newPassphrase = useCallback(() => { - setPassphrase(() => generatePassphrase(12)) - }, []) - - useEffect(() => { - setPassphrase(() => generatePassphrase(12)) - }, []) - - return ( -
-

Enable device sync

-

- Enable secure sync of sensitive data (like wallet credentials) between your devices. -

-

- After enabled, your passphrase can be used to connect other devices. -

-
{ - try { - await setSeedPassphrase(passphrase) - } catch (e) { - console.error(e) - toaster.danger('error setting passphrase') - } - }} - > - - - - } - /> -
-
-
- enable -
-
-
- -
- ) -} diff --git a/pages/wallets/[...slug].js b/pages/wallets/[...slug].js new file mode 100644 index 00000000..31059d72 --- /dev/null +++ b/pages/wallets/[...slug].js @@ -0,0 +1,20 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { WalletForms as WalletFormsComponent } from '@/wallets/client/components' +import { unurlify } from '@/wallets/lib/util' +import { useParams } from 'next/navigation' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function WalletForms () { + const params = useParams() + const walletName = unurlify(params.slug[0]) + + // if the wallet name is a number, we are showing a configured wallet + // otherwise, we are showing a template + const isNumber = !Number.isNaN(Number(walletName)) + if (isNumber) { + return + } + + return +} diff --git a/pages/wallets/[wallet].js b/pages/wallets/[wallet].js deleted file mode 100644 index 5b8f7ae5..00000000 --- a/pages/wallets/[wallet].js +++ /dev/null @@ -1,187 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, ClientInput, PasswordInput, CheckboxGroup, Checkbox } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { WalletSecurityBanner } from '@/components/banners' -import { WalletLogs } from '@/wallets/logger' -import { useToast } from '@/components/toast' -import { useRouter } from 'next/router' -import { useWallet } from '@/wallets/index' -import Info from '@/components/info' -import Text from '@/components/text' -import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared' -import { canReceive, canSend, isConfigured } from '@/wallets/common' -import { SSR } from '@/lib/constants' -import WalletButtonBar from '@/wallets/buttonbar' -import { useWalletConfigurator } from '@/wallets/config' -import { useCallback, useMemo } from 'react' -import { useMe } from '@/components/me' -import validateWallet from '@/wallets/validate' -import { ValidationError } from 'yup' -import { useFormikContext } from 'formik' -import { useWalletImage } from '@/wallets/image' -import styles from '@/styles/wallet.module.css' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function WalletSettings () { - const toaster = useToast() - const router = useRouter() - const { wallet: name } = router.query - const wallet = useWallet(name) - const { me } = useMe() - const { save, detach } = useWalletConfigurator(wallet) - const image = useWalletImage(wallet) - - const initial = useMemo(() => { - const initial = wallet?.def.fields.reduce((acc, field) => { - // We still need to run over all wallet fields via reduce - // even though we use wallet.config as the initial value - // since wallet.config is empty when wallet is not configured. - // Also, wallet.config includes general fields like - // 'enabled' and 'priority' which are not defined in wallet.fields. - return { - ...acc, - [field.name]: wallet?.config?.[field.name] || field.defaultValue || '' - } - }, wallet?.config) - - if (wallet?.def.fields.every(f => f.clientOnly)) { - return initial - } - - return { - ...initial, - ...autowithdrawInitial({ me }) - } - }, [wallet, me]) - - const validate = useCallback(async (data) => { - try { - await validateWallet(wallet.def, data, - { yupOptions: { abortEarly: false }, topLevel: false, skipGenerated: true }) - } catch (error) { - if (error instanceof ValidationError) { - return error.inner.reduce((acc, error) => { - acc[error.path] = error.message - return acc - }, {}) - } - throw error - } - }, [wallet.def]) - - return ( - - {image - ? - :

{wallet.def.card.title}

} -
{wallet.def.card.subtitle}
-
{ - try { - const newConfig = !isConfigured(wallet) - - // enable wallet if wallet was just configured - if (newConfig) { - values.enabled = true - } - - await save(values, values.enabled) - - toaster.success('saved settings') - router.push('/wallets') - } catch (err) { - console.error(err) - toaster.danger(err.message || err.toString?.()) - } - }} - > - - {wallet && } - - - - - { - try { - await detach() - toaster.success('saved settings') - router.push('/wallets') - } catch (err) { - console.error(err) - const message = 'failed to detach: ' + err.message || err.toString?.() - toaster.danger(message) - } - }} - /> - -
- {wallet && } -
-
- ) -} - -function SendWarningBanner ({ walletDef }) { - const { values } = useFormikContext() - if (!canSend({ def: walletDef, config: values }) || !walletDef.requiresConfig) return null - - return -} - -function ReceiveSettings ({ walletDef }) { - const { values } = useFormikContext() - return canReceive({ def: walletDef, config: values }) && -} - -function WalletFields ({ wallet }) { - return wallet.def.fields - .map(({ - name, label = '', type, help, optional, editable, requiredWithout, - validate, clientOnly, serverOnly, generated, ...props - }, i) => { - const rawProps = { - ...props, - name, - initialValue: wallet.config?.[name], - readOnly: !SSR && isConfigured(wallet) && editable === false && !!wallet.config?.[name], - groupClassName: props.hidden ? 'd-none' : undefined, - label: label - ? ( -
- {label} - {/* help can be a string or object to customize the label */} - {help && ( - - {help.text || help} - - )} - {optional && ( - - {typeof optional === 'boolean' ? 'optional' : {optional}} - - )} -
- ) - : undefined, - required: !optional, - autoFocus: i === 0 - } - if (type === 'text') { - return - } - if (type === 'password') { - return - } - return null - }) -} diff --git a/pages/wallets/index.js b/pages/wallets/index.js index 4ea53b90..a547e9ed 100644 --- a/pages/wallets/index.js +++ b/pages/wallets/index.js @@ -1,97 +1,76 @@ import { getGetServerSideProps } from '@/api/ssrApollo' -import Layout from '@/components/layout' -import styles from '@/styles/wallet.module.css' -import Link from 'next/link' -import { useWallets } from '@/wallets/index' -import { useCallback, useEffect, useState } from 'react' -import { useIsClient } from '@/components/use-client' -import WalletCard from '@/wallets/card' -import { useToast } from '@/components/toast' -import BootstrapForm from 'react-bootstrap/Form' -import RecvIcon from '@/svgs/arrow-left-down-line.svg' -import SendIcon from '@/svgs/arrow-right-up-line.svg' -import { useRouter } from 'next/router' -import { supportsReceive, supportsSend } from '@/wallets/common' -import { useWalletIndicator } from '@/wallets/indicator' import { Button } from 'react-bootstrap' +import { useWallets, useTemplates, DndProvider, Status, useStatus } from '@/wallets/client/context' +import { WalletCard, WalletLayout, WalletLayoutHeader, WalletLayoutLink, WalletLayoutSubHeader } from '@/wallets/client/components' +import styles from '@/styles/wallet.module.css' +import { usePassphrasePrompt, useShowPassphrase, useSetWalletPriorities } from '@/wallets/client/hooks' +import { WalletSearch } from '@/wallets/client/components/search' +import { useMemo, useState } from 'react' +import { walletDisplayName } from '@/wallets/lib/util' +import Moon from '@/svgs/moon-fill.svg' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -export default function Wallet ({ ssrData }) { - const { wallets, setPriorities } = useWallets() - const toast = useToast() - const isClient = useIsClient() - const [sourceIndex, setSourceIndex] = useState(null) - const [targetIndex, setTargetIndex] = useState(null) +export default function Wallet () { + const wallets = useWallets() + const status = useStatus() + const [showWallets, setShowWallets] = useState(false) + const templates = useTemplates() + const showPassphrase = useShowPassphrase() + const passphrasePrompt = usePassphrasePrompt() + const setWalletPriorities = useSetWalletPriorities() + const [searchFilter, setSearchFilter] = useState(() => (text) => true) - const router = useRouter() - const [filter, setFilter] = useState({ - send: router.query.send === 'true', - receive: router.query.receive === 'true' - }) - - const reorder = useCallback(async (sourceIndex, targetIndex) => { - const newOrder = [...wallets.filter(w => w.config?.enabled)] - const [source] = newOrder.splice(sourceIndex, 1) - - const priorities = newOrder.slice(0, targetIndex) - .concat(source) - .concat(newOrder.slice(targetIndex)) - .map((w, i) => ({ wallet: w, priority: i })) - - await setPriorities(priorities) - }, [setPriorities, wallets]) - - const onDragStart = useCallback((i) => (e) => { - // e.dataTransfer.dropEffect = 'move' - // We can only use the DataTransfer API inside the drop event - // see https://html.spec.whatwg.org/multipage/dnd.html#security-risks-in-the-drag-and-drop-model - // e.dataTransfer.setData('text/plain', name) - // That's why we use React state instead - setSourceIndex(i) - }, [setSourceIndex]) - - const onDragEnter = useCallback((i) => (e) => { - setTargetIndex(i) - }, [setTargetIndex]) - - const onReorderError = useCallback((err) => { - console.error(err) - toast.danger('failed to reorder wallets') - }, [toast]) - - const onDragEnd = useCallback((e) => { - setSourceIndex(null) - setTargetIndex(null) - - if (sourceIndex === targetIndex) return - - reorder(sourceIndex, targetIndex).catch(onReorderError) - }, [sourceIndex, targetIndex, reorder, onReorderError]) - - const onTouchStart = useCallback((i) => (e) => { - if (sourceIndex !== null) { - reorder(sourceIndex, i).catch(onReorderError) - setSourceIndex(null) - } else { - setSourceIndex(i) + const { wallets: filteredWallets, templates: filteredTemplates } = useMemo(() => { + const walletFilter = ({ name }) => searchFilter(walletDisplayName(name)) || searchFilter(name) + return { + wallets: wallets.filter(walletFilter), + templates: templates.filter(walletFilter) } - }, [sourceIndex, reorder, onReorderError]) + }, [wallets, templates, searchFilter]) - const onFilterChange = useCallback((key) => { - return e => { - setFilter(old => ({ ...old, [key]: e.target.checked })) - router.replace({ query: { ...router.query, [key]: e.target.checked } }, undefined, { shallow: true }) - } - }, [router]) - - const indicator = useWalletIndicator() - const [showWallets, setShowWallets] = useState(!indicator) - useEffect(() => { setShowWallets(!indicator) }, [indicator]) - - if (indicator && !showWallets) { + if (status === Status.LOADING_WALLETS) { return ( - + +
+ + loading wallets +
+
+ ) + } + + if (status === Status.PASSPHRASE_REQUIRED) { + return ( + +
+ + your passphrase is required +
+
+ ) + } + + if (status === Status.WALLETS_UNAVAILABLE) { + return ( + +
+ wallets unavailable + + this device does not support storage of cryptographic keys via IndexedDB + +
+
+ ) + } + + if (status === Status.NO_WALLETS && !showWallets) { + return ( +
attach a wallet to send and receive sats
-
+ ) } return ( - -
-

wallets

-
use real bitcoin
+ +
+ wallets + use real bitcoin
- - wallet logs - + wallet logs + + settings + {showPassphrase && ( + <> + + + + )}
-
-
- receive} - onChange={onFilterChange('receive')} - checked={filter.receive} - /> - send} - onChange={onFilterChange('send')} - checked={filter.send} - /> -
- { - wallets - .filter(w => { - return (!filter.send || (filter.send && supportsSend(w))) && - (!filter.receive || (filter.receive && supportsReceive(w))) - }) - .map((w, i) => { - const draggable = isClient && w.config?.enabled - - return ( -
- -
- ) - }) - } + + {filteredWallets.length > 0 && ( + <> + +
+ {filteredWallets.map((wallet, index) => ( + + ))} +
+
+
+ + )} +
+ {filteredTemplates.map((w, i) => )}
- + ) } diff --git a/pages/wallets/logs.js b/pages/wallets/logs.js index adecc877..eeb40a52 100644 --- a/pages/wallets/logs.js +++ b/pages/wallets/logs.js @@ -1,16 +1,15 @@ -import { CenterLayout } from '@/components/layout' import { getGetServerSideProps } from '@/api/ssrApollo' -import { WalletLogs } from '@/wallets/logger' +import { WalletLayout, WalletLayoutHeader, WalletLogs } from '@/wallets/client/components' -export const getServerSideProps = getGetServerSideProps({ query: null }) +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -export default function () { +export default function WalletLogsPage () { return ( - <> - -

wallet logs

+ +
+ wallet logs - - +
+
) } diff --git a/pages/wallets/settings.js b/pages/wallets/settings.js new file mode 100644 index 00000000..629e71a5 --- /dev/null +++ b/pages/wallets/settings.js @@ -0,0 +1,185 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { Checkbox, Form, Input, SubmitButton } from '@/components/form' +import Info from '@/components/info' +import { isNumber } from '@/lib/format' +import { WalletLayout, WalletLayoutHeader, WalletLayoutSubHeader } from '@/wallets/client/components' +import { useMutation, useQuery } from '@apollo/client' +import Link from 'next/link' +import { useCallback, useMemo } from 'react' +import { InputGroup } from 'react-bootstrap' +import styles from '@/styles/wallet.module.css' +import classNames from 'classnames' +import { useField } from 'formik' +import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments' +import { walletSettingsSchema } from '@/lib/validate' +import { useToast } from '@/components/toast' +import CancelButton from '@/components/cancel-button' + +export const getServerSideProps = getGetServerSideProps({ query: WALLET_SETTINGS, authRequired: true }) + +export default function WalletSettings ({ ssrData }) { + const { data } = useQuery(WALLET_SETTINGS) + const [setSettings] = useMutation(SET_WALLET_SETTINGS) + const { walletSettings: settings } = useMemo(() => data ?? ssrData, [data, ssrData]) + const toaster = useToast() + + const initial = { + receiveCreditsBelowSats: settings?.receiveCreditsBelowSats, + sendCreditsBelowSats: settings?.sendCreditsBelowSats, + autoWithdrawThreshold: settings?.autoWithdrawThreshold ?? 10000, + autoWithdrawMaxFeePercent: settings?.autoWithdrawMaxFeePercent ?? 1, + autoWithdrawMaxFeeTotal: settings?.autoWithdrawMaxFeeTotal ?? 1, + proxyReceive: settings?.proxyReceive + } + + const onSubmit = useCallback(async (values) => { + try { + await setSettings({ + variables: { + settings: values + } + }) + toaster.success('saved settings') + } catch (err) { + console.error(err) + toaster.danger('failed to save settings') + } + }, [toaster]) + + return ( + +
+ wallet settings + apply globally to all wallets +
+ + + + +
+ + save +
+ +
+
+ ) +} + +function CowboyCreditsSettings () { + return ( + <> + cowboy credits + sats} + type='number' + min={0} + /> + sats} + type='number' + min={0} + /> + + + ) +} + +function LightningAddressSettings () { + return ( + <> + @stacker.news lightning address + enhance privacy of my lightning address + +
    +
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • +
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • +
  • This will incur in a 10% fee
  • +
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • +
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • +
+
+
+ } + name='proxyReceive' + groupClassName='mb-0' + /> + + ) +} + +function AutowithdrawSettings () { + const [{ value: threshold }] = useField('autoWithdrawThreshold') + const sendThreshold = Math.max(Math.floor(threshold / 10), 1) + + return ( + <> + autowithdrawal + sats} + required + type='number' + min={0} + /> + + + ) +} + +function LightningNetworkFeesSettings () { + return ( + <> + lightning network fees +
+ we'll use whichever setting is higher during{' '} + pathfinding + +
+ %} + required + type='number' + min={0} + /> + sats} + required + type='number' + min={0} + /> + + ) +} + +function Separator ({ children, className }) { + return ( +
{children}
+ ) +} diff --git a/pages/withdraw.js b/pages/withdraw.js index 1e198d21..701d07d5 100644 --- a/pages/withdraw.js +++ b/pages/withdraw.js @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { InputGroup, Nav } from 'react-bootstrap' import styles from '@/components/user-header.module.css' import { gql, useMutation, useQuery } from '@apollo/client' -import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet' +import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/invoice' import { requestProvider } from 'webln' import { useEffect, useState } from 'react' import { useMe } from '@/components/me' diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index dd39d0a1..569e4653 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -4,7 +4,7 @@ import { CopyInput, Input, InputSkeleton } from '@/components/form' import InputGroup from 'react-bootstrap/InputGroup' import InvoiceStatus from '@/components/invoice-status' import { useRouter } from 'next/router' -import { WITHDRAWL } from '@/fragments/wallet' +import { WITHDRAWL } from '@/fragments/invoice' import Link from 'next/link' import { SSR, INVOICE_RETENTION_DAYS, FAST_POLL_INTERVAL } from '@/lib/constants' import { numWithUnits } from '@/lib/format' diff --git a/prisma/migrations/20250702000000_vault_refactor/migration.sql b/prisma/migrations/20250702000000_vault_refactor/migration.sql new file mode 100644 index 00000000..f5bef060 --- /dev/null +++ b/prisma/migrations/20250702000000_vault_refactor/migration.sql @@ -0,0 +1,208 @@ +/* + Warnings: + + - A unique constraint covering the columns `[apiKeyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[currencyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[adminKeyId]` on the table `WalletLNbits` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[nwcUrlId]` on the table `WalletNWC` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[primaryPasswordId]` on the table `WalletPhoenixd` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "WalletBlink" + ADD COLUMN "apiKeyId" INTEGER, + ADD COLUMN "currencyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletLNbits" ADD COLUMN "adminKeyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletNWC" ADD COLUMN "nwcUrlId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletPhoenixd" ADD COLUMN "primaryPasswordId" INTEGER; + +-- CreateTable +CREATE TABLE "Vault" ( + "id" SERIAL NOT NULL, + "iv" TEXT NOT NULL, + "value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Vault_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletLNC" ( + "id" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "pairingPhraseId" INTEGER, + "localKeyId" INTEGER, + "remoteKeyId" INTEGER, + "serverHostId" INTEGER, + + CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletWebLN" ( + "id" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WalletWebLN_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_apiKeyId_key" ON "WalletBlink"("apiKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_currencyId_key" ON "WalletBlink"("currencyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNbits_adminKeyId_key" ON "WalletLNbits"("adminKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletNWC_nwcUrlId_key" ON "WalletNWC"("nwcUrlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletPhoenixd_primaryPasswordId_key" ON "WalletPhoenixd"("primaryPasswordId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_pairingPhraseId_key" ON "WalletLNC"("pairingPhraseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_localKeyId_key" ON "WalletLNC"("localKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_remoteKeyId_key" ON "WalletLNC"("remoteKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_serverHostId_key" ON "WalletLNC"("serverHostId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletWebLN_walletId_key" ON "WalletWebLN"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_adminKeyId_fkey" FOREIGN KEY ("adminKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletNWC" ADD CONSTRAINT "WalletNWC_nwcUrlId_fkey" FOREIGN KEY ("nwcUrlId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_currencyId_fkey" FOREIGN KEY ("currencyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletPhoenixd" ADD CONSTRAINT "WalletPhoenixd_primaryPasswordId_fkey" FOREIGN KEY ("primaryPasswordId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_pairingPhraseId_fkey" FOREIGN KEY ("pairingPhraseId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_localKeyId_fkey" FOREIGN KEY ("localKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_remoteKeyId_fkey" FOREIGN KEY ("remoteKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_serverHostId_fkey" FOREIGN KEY ("serverHostId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletWebLN" ADD CONSTRAINT "WalletWebLN_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_lnc_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLNC" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE TRIGGER wallet_webln_as_jsonb +AFTER INSERT OR UPDATE ON "WalletWebLN" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE OR REPLACE FUNCTION migrate_wallet_vault() +RETURNS void AS +$$ +DECLARE + vaultEntry "VaultEntry"%ROWTYPE; +BEGIN + INSERT INTO "WalletWebLN"("walletId") SELECT id FROM "Wallet" WHERE type = 'WEBLN'; + INSERT INTO "WalletLNC"("walletId") SELECT id from "Wallet" WHERE type = 'LNC'; + + FOR vaultEntry IN SELECT * FROM "VaultEntry" LOOP + DECLARE + vaultId INT; + walletType "WalletType"; + BEGIN + INSERT INTO "Vault" ("iv", "value") + VALUES (vaultEntry."iv", vaultEntry."value") + RETURNING id INTO vaultId; + + SELECT type INTO walletType + FROM "Wallet" + WHERE id = vaultEntry."walletId"; + + CASE walletType + WHEN 'LNBITS' THEN + UPDATE "WalletLNbits" + SET "adminKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'NWC' THEN + UPDATE "WalletNWC" + SET "nwcUrlId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'BLINK' THEN + IF vaultEntry."key" = 'apiKey' THEN + UPDATE "WalletBlink" + SET "apiKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSE + UPDATE "WalletBlink" + SET "currencyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + WHEN 'PHOENIXD' THEN + UPDATE "WalletPhoenixd" + SET "primaryPasswordId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'LNC' THEN + IF vaultEntry."key" = 'pairingPhrase' THEN + UPDATE "WalletLNC" + SET "pairingPhraseId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'localKey' THEN + UPDATE "WalletLNC" + SET "localKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'remoteKey' THEN + UPDATE "WalletLNC" + SET "remoteKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'serverHost' THEN + UPDATE "WalletLNC" + SET "serverHostId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + END CASE; + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +SELECT migrate_wallet_vault(); +DROP FUNCTION migrate_wallet_vault(); + +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_userId_fkey"; +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_walletId_fkey"; +DROP TABLE "VaultEntry"; diff --git a/prisma/migrations/20250702000001_wallet_v2/migration.sql b/prisma/migrations/20250702000001_wallet_v2/migration.sql new file mode 100644 index 00000000..3fc7ef97 --- /dev/null +++ b/prisma/migrations/20250702000001_wallet_v2/migration.sql @@ -0,0 +1,1091 @@ +-- CreateEnum +CREATE TYPE "WalletProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'WEBLN', 'LN_ADDR', 'LNC', 'CLN_REST', 'LND_GRPC'); + +-- CreateEnum +CREATE TYPE "WalletSendProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'WEBLN', 'LNC'); + +-- CreateEnum +CREATE TYPE "WalletRecvProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'LN_ADDR', 'CLN_REST', 'LND_GRPC'); + +-- CreateEnum +CREATE TYPE "WalletName" AS ENUM ( + 'ALBY', + 'BLINK', + 'BLIXT', + 'CASHU_ME', + 'CLN', + 'COINOS', + 'FOUNTAIN', + 'LIFPAY', + 'LNBITS', + 'LND', + 'MINIBITS', + 'NPUB_CASH', + 'PHOENIXD', + 'PRIMAL', + 'RIZFUL', + 'SHOCKWALLET', + 'SPEED', + 'STRIKE', + 'VOLTAGE', + 'WALLET_OF_SATOSHI', + 'ZBD', + 'ZEUS', + 'NWC', + 'LN_ADDR', + 'CASH_APP' +); + +-- CreateTable +CREATE TABLE "WalletTemplate" ( + "name" "WalletName" NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sendProtocols" "WalletSendProtocolName"[], + "recvProtocols" "WalletRecvProtocolName"[], + + CONSTRAINT "WalletTemplate_pkey" PRIMARY KEY ("name") +); + +INSERT INTO "WalletTemplate" (name, "sendProtocols", "recvProtocols") VALUES + ('ALBY', + ARRAY['NWC', 'WEBLN']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('BLINK', + ARRAY['BLINK']::"WalletSendProtocolName"[], + ARRAY['BLINK', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('BLIXT', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CASHU_ME', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CLN', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['CLN_REST']::"WalletRecvProtocolName"[]), + ('COINOS', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('FOUNTAIN', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('LIFPAY', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('LNBITS', + ARRAY['LNBITS']::"WalletSendProtocolName"[], + ARRAY['LNBITS']::"WalletRecvProtocolName"[]), + ('LND', + ARRAY['LNC']::"WalletSendProtocolName"[], + ARRAY['LND_GRPC']::"WalletRecvProtocolName"[]), + ('MINIBITS', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('NPUB_CASH', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('PHOENIXD', + ARRAY['PHOENIXD']::"WalletSendProtocolName"[], + ARRAY['PHOENIXD']::"WalletRecvProtocolName"[]), + ('PRIMAL', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('RIZFUL', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('SHOCKWALLET', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('SPEED', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('STRIKE', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('VOLTAGE', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('WALLET_OF_SATOSHI', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('ZBD', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('ZEUS', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('NWC', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC']::"WalletRecvProtocolName"[]), + ('LN_ADDR', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CASH_APP', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]); + +ALTER TABLE "Wallet" RENAME TO "WalletV1"; +ALTER TABLE "WalletV1" RENAME CONSTRAINT "Wallet_pkey" TO "WalletV1_pkey"; +ALTER INDEX "Wallet_userId_idx" RENAME TO "WalletV1_userId_idx"; + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "priority" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER NOT NULL, + "templateName" "WalletName" NOT NULL, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateEnum +CREATE TYPE "WalletProtocolStatus" AS ENUM ('OK', 'WARNING', 'ERROR'); + +-- CreateTable +CREATE TABLE "WalletProtocol" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "config" JSONB, + "walletId" INTEGER NOT NULL, + "send" BOOLEAN NOT NULL, + "name" "WalletProtocolName" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "status" "WalletProtocolStatus" NOT NULL DEFAULT 'OK', + + CONSTRAINT "WalletProtocol_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendNWC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "urlVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendNWC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendLNbits" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendLNbits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendPhoenixd" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendPhoenixd_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendBlink" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "currencyVaultId" INTEGER NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendBlink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendWebLN" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendWebLN_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendLNC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "pairingPhraseVaultId" INTEGER NOT NULL, + "localKeyVaultId" INTEGER NOT NULL, + "remoteKeyVaultId" INTEGER NOT NULL, + "serverHostVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendLNC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvNWC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + + CONSTRAINT "WalletRecvNWC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLNbits" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvLNbits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvPhoenixd" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvPhoenixd_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvBlink" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvBlink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLightningAddress" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "address" TEXT NOT NULL, + + CONSTRAINT "WalletRecvLightningAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvCLNRest" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "socket" TEXT NOT NULL, + "rune" TEXT NOT NULL, + "cert" TEXT, + + CONSTRAINT "WalletRecvCLNRest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLNDGRPC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "socket" TEXT NOT NULL, + "macaroon" TEXT NOT NULL, + "cert" TEXT, + + CONSTRAINT "WalletRecvLNDGRPC_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Wallet_userId_idx" ON "Wallet"("userId"); + +-- CreateIndex +CREATE INDEX "Wallet_templateName_idx" ON "Wallet"("templateName"); + +-- CreateIndex +CREATE INDEX "WalletProtocol_walletId_idx" ON "WalletProtocol"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletProtocol_walletId_send_name_key" ON "WalletProtocol"("walletId", "send", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendNWC_protocolId_key" ON "WalletSendNWC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendNWC_urlVaultId_key" ON "WalletSendNWC"("urlVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNbits_protocolId_key" ON "WalletSendLNbits"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNbits_apiKeyVaultId_key" ON "WalletSendLNbits"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendPhoenixd_protocolId_key" ON "WalletSendPhoenixd"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendPhoenixd_apiKeyVaultId_key" ON "WalletSendPhoenixd"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_protocolId_key" ON "WalletSendBlink"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_apiKeyVaultId_key" ON "WalletSendBlink"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_currencyVaultId_key" ON "WalletSendBlink"("currencyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendWebLN_protocolId_key" ON "WalletSendWebLN"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_protocolId_key" ON "WalletSendLNC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_pairingPhraseVaultId_key" ON "WalletSendLNC"("pairingPhraseVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_localKeyVaultId_key" ON "WalletSendLNC"("localKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_remoteKeyVaultId_key" ON "WalletSendLNC"("remoteKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_serverHostVaultId_key" ON "WalletSendLNC"("serverHostVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvNWC_protocolId_key" ON "WalletRecvNWC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLNbits_protocolId_key" ON "WalletRecvLNbits"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvPhoenixd_protocolId_key" ON "WalletRecvPhoenixd"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvBlink_protocolId_key" ON "WalletRecvBlink"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLightningAddress_protocolId_key" ON "WalletRecvLightningAddress"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvCLNRest_protocolId_key" ON "WalletRecvCLNRest"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLNDGRPC_protocolId_key" ON "WalletRecvLNDGRPC"("protocolId"); + +-- AddForeignKey +ALTER TABLE "WalletProtocol" ADD CONSTRAINT "WalletProtocol_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendNWC" ADD CONSTRAINT "WalletSendNWC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendNWC" ADD CONSTRAINT "WalletSendNWC_urlVaultId_fkey" FOREIGN KEY ("urlVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNbits" ADD CONSTRAINT "WalletSendLNbits_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNbits" ADD CONSTRAINT "WalletSendLNbits_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendPhoenixd" ADD CONSTRAINT "WalletSendPhoenixd_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendPhoenixd" ADD CONSTRAINT "WalletSendPhoenixd_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_currencyVaultId_fkey" FOREIGN KEY ("currencyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendWebLN" ADD CONSTRAINT "WalletSendWebLN_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_pairingPhraseVaultId_fkey" FOREIGN KEY ("pairingPhraseVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_localKeyVaultId_fkey" FOREIGN KEY ("localKeyVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_remoteKeyVaultId_fkey" FOREIGN KEY ("remoteKeyVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_serverHostVaultId_fkey" FOREIGN KEY ("serverHostVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvNWC" ADD CONSTRAINT "WalletRecvNWC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLNbits" ADD CONSTRAINT "WalletRecvLNbits_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvPhoenixd" ADD CONSTRAINT "WalletRecvPhoenixd_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvBlink" ADD CONSTRAINT "WalletRecvBlink_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLightningAddress" ADD CONSTRAINT "WalletRecvLightningAddress_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvCLNRest" ADD CONSTRAINT "WalletRecvCLNRest_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLNDGRPC" ADD CONSTRAINT "WalletRecvLNDGRPC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_templateName_fkey" FOREIGN KEY ("templateName") REFERENCES "WalletTemplate"("name") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION wallet_check_support() +RETURNS TRIGGER AS $$ +DECLARE + template "WalletTemplate"; +BEGIN + SELECT t.* INTO template + FROM "Wallet" w + JOIN "WalletTemplate" t ON w."templateName" = t.name + WHERE w.id = NEW."walletId"; + + IF NEW."send" THEN + IF NOT NEW."name"::text::"WalletSendProtocolName" = ANY(template."sendProtocols") THEN + RAISE EXCEPTION 'Wallet % does not support send protocol %', template.name, NEW."name"; + END IF; + ELSE + IF NOT NEW."name"::text::"WalletRecvProtocolName" = ANY(template."recvProtocols") THEN + RAISE EXCEPTION 'Wallet % does not support receive protocol %', template.name, NEW."name"; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER wallet_check_support + AFTER INSERT OR UPDATE ON "WalletProtocol" + FOR EACH ROW + EXECUTE FUNCTION wallet_check_support(); + +CREATE OR REPLACE FUNCTION wallet_to_jsonb() +RETURNS TRIGGER AS $$ +DECLARE + wallet jsonb; + vault jsonb; + col_name text; + vault_id int; + base_name text; +BEGIN + wallet := to_jsonb(NEW); + + FOR col_name IN + SELECT key::text + FROM jsonb_each(wallet) + WHERE key::text LIKE '%VaultId' + LOOP + vault_id := (wallet->>col_name)::int; + -- remove 'VaultId' suffix + base_name := substring(col_name from 1 for length(col_name)-7); + + SELECT jsonb_build_object('id', v.id, 'iv', v.iv, 'value', v.value) INTO vault + FROM "Vault" v + WHERE v.id = vault_id; + + IF vault IS NOT NULL THEN + wallet := jsonb_set(wallet, array[base_name], vault) - col_name; + END IF; + END LOOP; + + UPDATE "WalletProtocol" + SET + config = wallet, + updated_at = NOW() + WHERE id = NEW."protocolId"; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendWebLN" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendLNC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLightningAddress" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvCLNRest" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLNDGRPC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE OR REPLACE FUNCTION wallet_clear_vault() +RETURNS TRIGGER AS $$ +DECLARE + wallet jsonb; + col_name text; + vault_id int; +BEGIN + wallet := to_jsonb(OLD); + + FOR col_name IN + SELECT key::text + FROM jsonb_each(wallet) + WHERE key::text LIKE '%VaultId' + LOOP + vault_id := (wallet->>col_name)::int; + DELETE FROM "Vault" WHERE id = vault_id; + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendWebLN" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendLNC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE OR REPLACE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$ +DECLARE + user_id INT; +BEGIN + IF TG_TABLE_NAME = 'WalletProtocol' THEN + SELECT w."userId" INTO user_id + FROM "Wallet" w + WHERE w.id = CASE + WHEN TG_OP = 'DELETE' THEN OLD."walletId" + ELSE NEW."walletId" + END; + ELSE + SELECT w."userId" INTO user_id + FROM "Wallet" w + WHERE w.id = NEW.id; + END IF; + + UPDATE "users" u + SET "walletsUpdatedAt" = NOW() + WHERE u.id = user_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER wallet_updated_at_trigger +AFTER INSERT OR UPDATE OR DELETE ON "WalletProtocol" +FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger(); + +CREATE OR REPLACE TRIGGER wallet_updated_at_trigger +AFTER INSERT OR UPDATE OR DELETE ON "Wallet" +FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger(); + +CREATE OR REPLACE FUNCTION user_auto_withdraw() RETURNS TRIGGER AS $$ +DECLARE +BEGIN + INSERT INTO pgboss.job (name, data) + SELECT 'autoWithdraw', jsonb_build_object('id', NEW.id) + -- only if there isn't already a pending job for this user + WHERE NOT EXISTS ( + SELECT * + FROM pgboss.job + WHERE name = 'autoWithdraw' + AND data->>'id' = NEW.id::TEXT + AND state = 'created' + ) + AND EXISTS ( + SELECT * + FROM "Wallet" w + JOIN "WalletProtocol" wp ON w.id = wp."walletId" + WHERE w."userId" = NEW.id + AND wp."enabled" = true + AND wp.send = false + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION get_or_create_wallet( + user_id INT, + template_name "WalletName", + priority INT +) +RETURNS INT AS +$$ +DECLARE + walletId INT; +BEGIN + SELECT w.id INTO walletId + FROM "Wallet" w + WHERE w."userId" = user_id AND w."templateName" = template_name; + + IF NOT FOUND THEN + walletId := create_wallet(user_id, template_name, priority); + END IF; + + RETURN walletId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_wallet( + user_id INT, + template_name "WalletName", + priority INT +) +RETURNS INT AS +$$ +DECLARE + walletId INT; +BEGIN + INSERT INTO "Wallet" ("userId", "templateName", "priority") + SELECT user_id, template_name, priority + FROM "WalletTemplate" t + WHERE t.name = template_name + RETURNING id INTO walletId; + + RETURN walletId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_wallet_protocol( + id INT, + wallet_id INT, + send BOOLEAN, + protocol_name "WalletProtocolName", + enabled BOOLEAN +) +RETURNS INT AS +$$ +DECLARE + protocolId INT; +BEGIN + INSERT INTO "WalletProtocol" ("id", "walletId", "send", "name", "enabled") + VALUES (CASE WHEN send THEN nextval('"WalletProtocol_id_seq"') ELSE id END, wallet_id, send, protocol_name, enabled) + RETURNING "WalletProtocol"."id" INTO protocolId; + + RETURN protocolId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION wallet_v2_migration() +RETURNS void AS +$$ +DECLARE + row RECORD; +BEGIN + -- In the old wallet schema, send and receive were stored in the same table that linked to a row in the Wallet table. + -- Foreign keys in other tables pointed to that row in the Wallet table. + -- In the new schema, send and receive are stored in separate tables and they point to individual rows in the WalletProtocol table. + -- Therefore, to be able to point the foreign keys to the new WalletProtocol table, we need to keep the same id, but only for the receive wallets + -- because that's what the foreign keys were pointing to in the old schema. + -- To avoid generating an id via the sequence that we already inserted manually, we let the sequence start at the highest Wallet id of the old schema. + PERFORM setval('"WalletProtocol_id_seq"', (SELECT MAX(id) FROM "WalletV1")); + + FOR row IN + SELECT w1.*, w2."userId", w2."priority", w2."enabled" + FROM "WalletLNbits" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LNBITS', row."priority"); + + IF row."adminKeyId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'LNBITS', row."enabled"); + INSERT INTO "WalletSendLNbits" ("protocolId", "url", "apiKeyVaultId") + VALUES (protocolId, row."url", row."adminKeyId"); + END IF; + + IF NULLIF(row."invoiceKey", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LNBITS', row."enabled"); + INSERT INTO "WalletRecvLNbits" ("protocolId", "url", "apiKey") + VALUES (protocolId, row."url", row."invoiceKey"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletPhoenixd" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'PHOENIXD', row."priority"); + + IF row."primaryPasswordId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'PHOENIXD', row."enabled"); + INSERT INTO "WalletSendPhoenixd" ("protocolId", "url", "apiKeyVaultId") + VALUES (protocolId, row."url", row."primaryPasswordId"); + END IF; + + IF NULLIF(row."secondaryPassword", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'PHOENIXD', row."enabled"); + INSERT INTO "WalletRecvPhoenixd" ("protocolId", "url", "apiKey") + VALUES (protocolId, row."url", row."secondaryPassword"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletBlink" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'BLINK', row."priority"); + + IF row."apiKeyId" IS NOT NULL AND row."currencyId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'BLINK', row."enabled"); + INSERT INTO "WalletSendBlink" ("protocolId", "apiKeyVaultId", "currencyVaultId") + VALUES (protocolId, row."apiKeyId", row."currencyId"); + END IF; + + IF NULLIF(row."apiKeyRecv", '') IS NOT NULL AND NULLIF(row."currencyRecv", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'BLINK', row."enabled"); + INSERT INTO "WalletRecvBlink" ("protocolId", "apiKey", "currency") + VALUES (protocolId, row."apiKeyRecv", row."currencyRecv"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLND" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LND', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LND_GRPC', row."enabled"); + INSERT INTO "WalletRecvLNDGRPC" ("protocolId", "socket", "macaroon", "cert") + VALUES (protocolId, row."socket", row."macaroon", row."cert"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLNC" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LND', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'LNC', row."enabled"); + INSERT INTO "WalletSendLNC" ("protocolId", "pairingPhraseVaultId", "localKeyVaultId", "remoteKeyVaultId", "serverHostVaultId") + VALUES (protocolId, row."pairingPhraseId", row."localKeyId", row."remoteKeyId", row."serverHostId"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletCLN" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'CLN', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'CLN_REST', row."enabled"); + INSERT INTO "WalletRecvCLNRest" ("protocolId", "socket", "rune", "cert") + VALUES (protocolId, row."socket", row."rune", row."cert"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletNWC" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + relay TEXT; + walletName "WalletName"; + BEGIN + relay := substring(row."nwcUrlRecv" from 'relay=([^&]+)'); + + IF relay LIKE '%getalby.com%' THEN + walletName := 'ALBY'; + ELSIF relay LIKE '%rizful.com%' THEN + walletName := 'RIZFUL'; + ELSIF relay LIKE '%primal.net%' THEN + walletName := 'PRIMAL'; + ELSIF relay LIKE '%coinos.io%' THEN + walletName := 'COINOS'; + ELSE + walletName := 'NWC'; + END IF; + + walletId := get_or_create_wallet(row."userId", walletName, row."priority"); + + -- we assume here that the wallet to receive is the same as the wallet to send + -- since we can't check which relay is used for the send connection because it's encrypted. + -- but in 99% if not 100% of the cases, it's the same wallet. + IF NULLIF(row."nwcUrlRecv", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'NWC', row."enabled"); + INSERT INTO "WalletRecvNWC" ("protocolId", "url") + VALUES (protocolId, row."nwcUrlRecv"); + END IF; + + IF row."nwcUrlId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'NWC', row."enabled"); + INSERT INTO "WalletSendNWC" ("protocolId", "urlVaultId") + VALUES (protocolId, row."nwcUrlId"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLightningAddress" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + domain TEXT; + walletName "WalletName"; + BEGIN + domain := split_part(row."address", '@', 2); + + IF domain LIKE '%walletofsatoshi.com' THEN + walletName := 'WALLET_OF_SATOSHI'; + ELSIF domain LIKE '%getalby.com' THEN + walletName := 'ALBY'; + ELSIF domain LIKE '%coinos.io' THEN + walletName := 'COINOS'; + ELSIF domain LIKE '%speed.app' OR domain LIKE '%tryspeed.com' THEN + walletName := 'SPEED'; + ELSIF domain LIKE '%blink.sv' THEN + walletName := 'BLINK'; + ELSIF domain LIKE '%zbd.gg' THEN + walletName := 'ZBD'; + ELSIF domain LIKE '%strike.me' THEN + walletName := 'STRIKE'; + ELSIF domain LIKE '%primal.net' THEN + walletName := 'PRIMAL'; + ELSIF domain LIKE '%minibits.cash' THEN + walletName := 'MINIBITS'; + ELSIF domain LIKE '%npub.cash' THEN + walletName := 'NPUB_CASH'; + ELSIF domain LIKE '%zeuspay.com' THEN + walletName := 'ZEUS'; + ELSIF domain LIKE '%fountain.fm' THEN + walletName := 'FOUNTAIN'; + ELSIF domain LIKE '%lifpay.me' THEN + walletName := 'LIFPAY'; + ELSIF domain LIKE '%rizful.com' THEN + walletName := 'RIZFUL'; + ELSIF domain LIKE '%vlt.ge' THEN + walletName := 'VOLTAGE'; + ELSIF domain LIKE '%blixtwallet.com' THEN + walletName := 'BLIXT'; + ELSIF domain LIKE '%shockwallet.app' THEN + walletName := 'SHOCKWALLET'; + ELSE + walletName := 'LN_ADDR'; + END IF; + + walletId := get_or_create_wallet(row."userId", walletName, row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LN_ADDR', row."enabled"); + INSERT INTO "WalletRecvLightningAddress" ("protocolId", "address") + VALUES (protocolId, row."address"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletWebLN" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'ALBY', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'WEBLN', row."enabled"); + INSERT INTO "WalletSendWebLN" ("protocolId") + VALUES (protocolId); + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +SELECT wallet_v2_migration(); + +DROP FUNCTION wallet_v2_migration(); +DROP FUNCTION get_or_create_wallet(INT, "WalletName", INT); +DROP FUNCTION create_wallet(INT, "WalletName", INT); +DROP FUNCTION create_wallet_protocol(INT, INT, BOOLEAN, "WalletProtocolName", BOOLEAN); + +-- drop old tables +DROP TABLE "WalletBlink"; +DROP TABLE "WalletCLN"; +DROP TABLE "WalletLNC"; +DROP TABLE "WalletLND"; +DROP TABLE "WalletLNbits"; +DROP TABLE "WalletLightningAddress"; +DROP TABLE "WalletNWC"; +DROP TABLE "WalletPhoenixd"; +DROP TABLE "WalletWebLN"; + +-- update foreign keys +ALTER TABLE "Withdrawl" DROP CONSTRAINT "Withdrawl_walletId_fkey"; +ALTER TABLE "Withdrawl" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "Withdrawl" ADD CONSTRAINT "Withdrawl_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER INDEX "Withdrawl_walletId_idx" RENAME TO "Withdrawl_protocolId_idx"; + +ALTER TABLE "DirectPayment" DROP CONSTRAINT "DirectPayment_walletId_fkey"; +ALTER TABLE "DirectPayment" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "InvoiceForward" DROP CONSTRAINT "InvoiceForward_walletId_fkey"; +ALTER TABLE "InvoiceForward" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "InvoiceForward" ADD CONSTRAINT "InvoiceForward_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER INDEX "InvoiceForward_walletId_idx" RENAME TO "InvoiceForward_protocolId_idx"; + +-- now drop Wallet table because nothing points to it anymore +DROP TABLE "WalletV1"; + +-- drop old function used for the JSON trigger +DROP FUNCTION wallet_wallet_type_as_jsonb; + +-- wallet logs now point to the new WalletProtocol table instead of to the old WalletType enum +ALTER TABLE "WalletLog" + DROP COLUMN "wallet", + ADD COLUMN "protocolId" INTEGER; + +DROP TYPE "WalletType"; +ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "showPassphrase" BOOLEAN NOT NULL DEFAULT true; + +-- Update LogLevel enum to be more consistent with wallet logger API +ALTER TYPE "LogLevel" RENAME TO "LogLevelV1"; +CREATE TYPE "LogLevel" AS ENUM ('OK', 'DEBUG', 'INFO', 'WARNING', 'ERROR'); +ALTER TABLE "WalletLog" ALTER COLUMN "level" TYPE "LogLevel" USING (CASE WHEN "level"::text = 'SUCCESS' THEN 'OK'::"LogLevel" WHEN "level"::text = 'WARN' THEN 'WARNING'::"LogLevel" ELSE "level"::text::"LogLevel" END); +ALTER TABLE "Log" ALTER COLUMN "level" TYPE "LogLevel" USING (CASE WHEN "level"::text = 'SUCCESS' THEN 'OK'::"LogLevel" WHEN "level"::text = 'WARN' THEN 'WARNING'::"LogLevel" ELSE "level"::text::"LogLevel" END); +DROP TYPE "LogLevelV1"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2be2378f..e6a0ca17 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,8 +145,8 @@ model User { oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") vaultKeyHash String @default("") + showPassphrase Boolean @default(true) walletsUpdatedAt DateTime? - vaultEntries VaultEntry[] @relation("VaultEntries") proxyReceive Boolean @default(true) DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") @@ -202,157 +202,42 @@ model UserSubTrust { @@id([userId, subName]) } -enum WalletType { - LIGHTNING_ADDRESS - LND - CLN - LNBITS - NWC - PHOENIXD - BLINK - LNC - WEBLN -} - -model Wallet { +model Vault { id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - label String? - enabled Boolean @default(true) - priority Int @default(0) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - // NOTE: this denormalized json field exists to make polymorphic joins efficient - // when reading wallets ... it is populated by a trigger when wallet descendants update - // otherwise reading wallets would require a join on every descendant table - // which might not be numerous for wallets but would be for other tables - // so this is a pattern we use only to be consistent with future polymorphic tables - // because it gives us fast reads and type safe writes - type WalletType - wallet Json? @db.JsonB - walletLightningAddress WalletLightningAddress? - walletLND WalletLND? - walletCLN WalletCLN? - walletLNbits WalletLNbits? - walletNWC WalletNWC? - walletPhoenixd WalletPhoenixd? - walletBlink WalletBlink? - - vaultEntries VaultEntry[] @relation("VaultEntries") - withdrawals Withdrawl[] - InvoiceForward InvoiceForward[] - DirectPayment DirectPayment[] - - @@unique([userId, type]) - @@index([userId]) - @@index([priority]) -} - -model VaultEntry { - id Int @id @default(autoincrement()) - key String @db.Text iv String @db.Text value String @db.Text - userId Int - walletId Int? - user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - @@unique([userId, key]) - @@index([walletId]) + walletSendNWC WalletSendNWC? + walletSendLNbits WalletSendLNbits? + walletSendPhoenixd WalletSendPhoenixd? + walletSendBlinkApiKey WalletSendBlink? @relation("blinkApiKeySend") + walletSendBlinkCurrency WalletSendBlink? @relation("blinkCurrencySend") + walletSendLNCPairingPhrase WalletSendLNC? @relation("lncPairingPhrase") + walletSendLNCLocalKey WalletSendLNC? @relation("lncLocalKey") + walletSendLNCRemoteKey WalletSendLNC? @relation("lncRemoteKey") + walletSendLNCServerHost WalletSendLNC? @relation("lncServerHost") } model WalletLog { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - wallet WalletType? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + protocolId Int? + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: Cascade) level LogLevel message String invoiceId Int? - invoice Invoice? @relation(fields: [invoiceId], references: [id]) + invoice Invoice? @relation(fields: [invoiceId], references: [id]) withdrawalId Int? - withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) - context Json? @db.JsonB + withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) + context Json? @db.JsonB @@index([userId, createdAt]) } -model WalletLightningAddress { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - address String -} - -model WalletLND { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - socket String - macaroon String - cert String? -} - -model WalletCLN { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - socket String - rune String - cert String? -} - -model WalletLNbits { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - url String - invoiceKey String? -} - -model WalletNWC { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - nwcUrlRecv String? -} - -model WalletBlink { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - apiKeyRecv String? - currencyRecv String? -} - -model WalletPhoenixd { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - url String - secondaryPassword String? -} - model Mute { muterId Int mutedId Int @@ -1005,22 +890,22 @@ model Invoice { } model DirectPayment { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") senderId Int? receiverId Int? - preimage String? @unique + preimage String? @unique bolt11 String? - hash String? @unique + hash String? @unique desc String? comment String? lud18Data Json? msats BigInt - walletId Int? - sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade) - receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade) - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + protocolId Int? + sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade) + receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade) + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) @@index([createdAt]) @@index([senderId]) @@ -1033,7 +918,7 @@ model InvoiceForward { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") bolt11 String maxFeeMsats Int - walletId Int + protocolId Int // we get these values when the invoice is held expiryHeight Int? @@ -1043,12 +928,12 @@ model InvoiceForward { invoiceId Int @unique withdrawlId Int? @unique - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) @@index([invoiceId]) - @@index([walletId]) + @@index([protocolId]) @@index([withdrawlId]) } @@ -1066,16 +951,16 @@ model Withdrawl { msatsFeePaid BigInt? status WithdrawlStatus? autoWithdraw Boolean @default(false) - walletId Int? + protocolId Int? user User @relation(fields: [userId], references: [id], onDelete: Cascade) - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) invoiceForward InvoiceForward? WalletLog WalletLog[] @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") @@index([hash]) - @@index([walletId]) + @@index([protocolId]) @@index([autoWithdraw]) @@index([status]) } @@ -1292,9 +1177,283 @@ enum WithdrawlStatus { } enum LogLevel { + OK DEBUG INFO - WARN + WARNING ERROR - SUCCESS +} + +// =================== +// ==== WALLET V2 ==== +// =================== + +enum WalletProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + WEBLN + LN_ADDR + LNC + CLN_REST + LND_GRPC +} + +enum WalletSendProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + WEBLN + LNC +} + +enum WalletRecvProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + LN_ADDR + CLN_REST + LND_GRPC +} + +enum WalletProtocolStatus { + OK + WARNING + ERROR +} + +enum WalletName { + ALBY + BLINK + BLIXT + CASHU_ME + CLN + COINOS + FOUNTAIN + LIFPAY + LNBITS + LND + MINIBITS + NPUB_CASH + PHOENIXD + PRIMAL + RIZFUL + SHOCKWALLET + SPEED + STRIKE + VOLTAGE + WALLET_OF_SATOSHI + ZBD + ZEUS + NWC + LN_ADDR + CASH_APP +} + +model WalletTemplate { + name WalletName @id + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + sendProtocols WalletSendProtocolName[] + recvProtocols WalletRecvProtocolName[] + wallets Wallet[] +} + +model Wallet { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + priority Int @default(0) + userId Int + templateName WalletName + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + template WalletTemplate @relation(fields: [templateName], references: [name], onDelete: Cascade) + + protocols WalletProtocol[] + + @@index([userId]) + @@index([templateName]) +} + +model WalletProtocol { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + // NOTE: this denormalized json field exists to make polymorphic joins efficient + // when reading wallets ... it's populated by a trigger when wallet descendants update + // otherwise reading wallets would require a join on every descendant table. + // this pattern gives us fast reads and fast, type safe writes + config Json? @db.JsonB + + walletId Int + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + send Boolean + name WalletProtocolName + enabled Boolean @default(true) + status WalletProtocolStatus @default(OK) + + withdrawals Withdrawl[] + directPayments DirectPayment[] + invoiceForward InvoiceForward[] + logs WalletLog[] + + walletSendNWC WalletSendNWC? + walletSendLNbits WalletSendLNbits? + walletSendPhoenixd WalletSendPhoenixd? + walletSendBlink WalletSendBlink? + walletSendWebLN WalletSendWebLN? + walletSendLNC WalletSendLNC? + + walletRecvNWC WalletRecvNWC? + walletRecvLNbits WalletRecvLNbits? + walletRecvPhoenixd WalletRecvPhoenixd? + walletRecvBlink WalletRecvBlink? + walletRecvLightningAddress WalletRecvLightningAddress? + walletRecvCLNRest WalletRecvCLNRest? + walletRecvLNDGRPC WalletRecvLNDGRPC? + + @@index([walletId]) + @@unique(name: "WalletProtocol_walletId_send_name_key", [walletId, send, name]) +} + +model WalletSendNWC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + urlVaultId Int @unique + url Vault @relation(fields: [urlVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendLNbits { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKeyVaultId Int @unique + apiKey Vault @relation(fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendPhoenixd { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKeyVaultId Int @unique + apiKey Vault @relation(fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendBlink { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + currencyVaultId Int @unique + currency Vault @relation("blinkCurrencySend", fields: [currencyVaultId], references: [id], onDelete: Cascade) + apiKeyVaultId Int @unique + apiKey Vault @relation("blinkApiKeySend", fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendWebLN { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) +} + +model WalletSendLNC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + pairingPhraseVaultId Int @unique + pairingPhrase Vault? @relation("lncPairingPhrase", fields: [pairingPhraseVaultId], references: [id]) + localKeyVaultId Int @unique + localKey Vault? @relation("lncLocalKey", fields: [localKeyVaultId], references: [id]) + remoteKeyVaultId Int @unique + remoteKey Vault? @relation("lncRemoteKey", fields: [remoteKeyVaultId], references: [id]) + serverHostVaultId Int @unique + serverHost Vault? @relation("lncServerHost", fields: [serverHostVaultId], references: [id]) +} + +model WalletRecvNWC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String +} + +model WalletRecvLNbits { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKey String +} + +model WalletRecvPhoenixd { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKey String +} + +model WalletRecvBlink { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + currency String + apiKey String +} + +model WalletRecvLightningAddress { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + address String +} + +model WalletRecvCLNRest { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + socket String + rune String + cert String? +} + +model WalletRecvLNDGRPC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + socket String + macaroon String + cert String? } diff --git a/public/wallets/alby-dark.svg b/public/wallets/alby-dark.svg new file mode 100644 index 00000000..b9e0a5b4 --- /dev/null +++ b/public/wallets/alby-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/alby.svg b/public/wallets/alby.svg new file mode 100644 index 00000000..073e825f --- /dev/null +++ b/public/wallets/alby.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/blixt-dark.svg b/public/wallets/blixt-dark.svg new file mode 100644 index 00000000..d2142252 --- /dev/null +++ b/public/wallets/blixt-dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/blixt.svg b/public/wallets/blixt.svg new file mode 100644 index 00000000..d1b84601 --- /dev/null +++ b/public/wallets/blixt.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/cashapp-dark.webp b/public/wallets/cashapp-dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..5aff8a3f2c567c68192638457447ff540cf139b6 GIT binary patch literal 21632 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

(h`3|l{ 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

(h`3|l{ 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 B_5=U` literal 0 HcmV?d00001 diff --git a/public/wallets/cashu.me.png b/public/wallets/cashu.me.png new file mode 100644 index 0000000000000000000000000000000000000000..707ccb10c88dd132d82355c88cacddbb6c398550 GIT binary patch literal 24564 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 literal 0 HcmV?d00001 diff --git a/public/wallets/coinos-dark.svg b/public/wallets/coinos-dark.svg new file mode 100644 index 00000000..e262e11a --- /dev/null +++ b/public/wallets/coinos-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/wallets/coinos.svg b/public/wallets/coinos.svg new file mode 100644 index 00000000..eb7a582f --- /dev/null +++ b/public/wallets/coinos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/fountain-dark.png b/public/wallets/fountain-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..59a2e4fc7c1f2bfdb40a63479b802729ac560b24 GIT binary patch literal 14055 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 literal 0 HcmV?d00001 diff --git a/public/wallets/fountain.png b/public/wallets/fountain.png new file mode 100644 index 0000000000000000000000000000000000000000..28f0c435b95cae50492ea242ffc4934bdfe9cb51 GIT binary patch literal 13634 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 literal 0 HcmV?d00001 diff --git a/public/wallets/lifpay-dark.png b/public/wallets/lifpay-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a0faaa99a4797c39baf321e797debaab16b051 GIT binary patch literal 10449 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 literal 0 HcmV?d00001 diff --git a/public/wallets/lifpay.png b/public/wallets/lifpay.png new file mode 100644 index 0000000000000000000000000000000000000000..ef2cd3066a3106122b12744924e1d652cec3770c GIT binary patch literal 10545 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

@tS@6KYL|o2ZJF<89 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 QFaQ7m07*qoM6N<$f`ehcqW}N^ literal 0 HcmV?d00001 diff --git a/public/wallets/lnaddr.png b/public/wallets/lnaddr.png new file mode 100644 index 0000000000000000000000000000000000000000..1b67c92fd0b3956467e695b74249f6c3dadfb290 GIT binary patch literal 9237 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 literal 0 HcmV?d00001 diff --git a/public/wallets/minibits-dark.png b/public/wallets/minibits-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4a9dd0b54c77e1cadd04e168e9c304623f91846a GIT binary patch literal 3970 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 literal 0 HcmV?d00001 diff --git a/public/wallets/minibits.png b/public/wallets/minibits.png new file mode 100644 index 0000000000000000000000000000000000000000..480de142bb00e52865c581ee4e74112b7763a5df GIT binary patch literal 3647 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 + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/npub-cash.svg b/public/wallets/npub-cash.svg new file mode 100644 index 00000000..d1d5defa --- /dev/null +++ b/public/wallets/npub-cash.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/nwc-dark.png b/public/wallets/nwc-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..79c52468646d87596862423b713a37ce75e110b5 GIT binary patch literal 36852 zcmYg%V_;<4)@?dTcWj##+qOHlZQJNL>DW#>wr$(CZCfwr_PO7EKdNdM*4lf{J?5CK zP+4hF7)VS=ARr(ZaWNrzARyp_uj4E5A79Usq1kU=FA%n3Y7Rg^sDOVS-^AsKuf9I~ z<{&RB08}x7ef;$X)P!G(9|)*A3hGVoI}lJ*sJIZnqU*QwcaLZzHD}xapHpS0`*=Kg zX5K1ek=zu^<;20*{o)vU)0IdaBN(`!%3`xtmcDedO0jtK@C6pOs9#8mU>2+OVvn3}y`yagOov$I+mCUy_Yr}dNVY6)u3 zEr%QTPMa-{nS(s9XATUv8k^4xD*;0KpU?cC zZk3-+i%zaMXmp<+TB$@aV#J7{LOam>V#EvZf#IJ)u&l{fo}W%wqCy0x@2iK85S1k2 zGXMOB&<<5hKU0+woWQ5LvsOo5_zkoQY~Uj6ncp4d>)U@eDhwb#2(9mi+dM#<)_Vkn zaP$8N-j}ZYvw4AX$Nir@V#LzvVv|WpqWA$OZ8JzWhjt@JQP;mX);<_Ne|Vw(yANr| z*FNIA!Om_L15K+wYdsi#=&e34V8<-~T*ud$q`}3ixZ5`n*3UE8Pw@Y)0QzS|FGaMyFj(7NFK-v;e*`CtVI!FtiA_dU4viOe3^-N0 z&9}<2!Rj9abzqniX!oF8qz+cfrNrfm5 z3c){S8m+bo8oF!Cz4Le(sHiT--kXxd&7aD>hq3UzanL&Lvv_!9>YX4+_(s6uF%NYJ zxYA18{_afLEley8{g!`D>H5@h*nLMnWr+o+X8h-jJZ*q4!~5W7;Jty#)AfQ3`=;bP;3N6EgSGx zlX3WQyjk#Xw?4LDOt12lmO2V7+*GXg<2Qhr8;Y>FcScL`2zP6qq-_QIT$_75?d{Hw za~~6MP!=EslKt;qo3$1H>W7F3<(5bve~{6=yl_cH3(~}y7g9-|8~$S3WBHe5shV0L zQ^)e)I#vBTKf8$3f4hYuo2&0|w_s zhz_@u^}58^RVc=Ln!qqOP!K#tl+-Gf(LyjXf~=B_JW422z@l0(7dSWev)&)@Fc75{ zp-%-yplB|4N&c(wU&IH&pr31v>qlz0>zU*_^qUa1>MC6YesG(pJXq{_9nfc<_*ec> z05)wQY`hS*;m485;QXAb+n=tF$E~MA6N{noaLL;W|HUQn%!oZWvi!#EHs?Mge+ETn z*KqDRx4%LI%yGJ17Db9m(r*<@-}CW=W~f>`K#=my2M0~NgV)LZ0BirXDM+?NtOe+2a|Ea>-{MxtN0BEqnWX{+EX#6N;hr@zRZ8DBgW`)wUEqrlX z3gHQ=l+<9?QIXf?!^sr=7YPJ0zn_Wz7HcBk7=Lhbz@M`y9u-@t zVN+M+4z?zRD!V^1u~88HNoxFfozPf0dv8X5cq%ypWlu8o*Iq>VDGtDX`CMdmbZY_j z1ECeRdo>a1)%I)v``A>6RND5WM+fp49W8Aas0rD6YRD^pYG|8TGq{H1@4$^MFL{C< z??;~3kI4>CrAMGfB>%`<7Mf@N&l#eC*_48ZW*Lkuv;c}Jp47wr=38w1}4gJdV zMo$x8^1G-kEYyhSX~r;*BmIY`uzwUMgCXa)jn#-#ktZ^=%FNYiJuet3brl5Ja++(| zn-Uh{NZi?N--EQ`nsS=q+$~jR8xoR}<2geMbtbCZ?fQggb=1W+BuWwQe@}Y6M*J6p zxcH-TYktT~UF_zNsj$-No1z@~`lN_@V3p@jo-{K=K3#L(D6v8$XNDC%V(OMLH?T~YTSkXGK! z?b-UKs2sPUK;PR@++uR`g==S$d!!e6IO%MZHtbju+hYN`90&uS=CYBsB^V)=~ zYc)3p|1wgH81}CS;)6Hdw^`1k-|`hoQcL4_y@mp3xTs`KtQ%+NAuz&29?6G{%JS9m zQdXe@AMvsRax0_;C%Ktv+yxzQ+*Y9X?roT6efT*(yo~!?;?LjyKQaLdfDt6v=QdiW z&N9>NqeCUyTOi6gQz_e578cI8u)meyjx5>{sZa2+ShuV0Gfis|kfO&nk6UG!Bbq&8 zTWP?Zd@1ob8J=su++Ck7{kr(K=_ByPPM@TMf9VQcFBkdNH{L;JqCE1h&nX>?uIc7I?%2Jf&Yx?X zSLNW=r*58V5hB`Cd=;azshfXyw4J}+U2TVwf89~I;12<=C&=yP^#8IWI#zb_tfU`0qjpq}(VnEcZ1m}WIRQ@vp7ZC`pKn^?UJ z0PZv<7>I(>=@hj>6?{W;pRXZi)W5!_ox!1BQ7S6Z)#*9I9Z!1KgovoVZ4VPX9x8smk-t^gmh~ z?A?D?{FfE$RDbI8MB5|FS1?*cvR1)Kr+UbjE6G8sYoAS6xz^-EY*eZ7QD#IwSWCUs zoeRzoyvjxLxld7XGhkERYQ zKrCDTI1-VaaYbTm-K!dQAAzpEgY?i>-1vOTwEKyEonrV8vG0RHbHrcHgy>dBxRn@j z8$2iSZ41himZ<_&pP_V4s7gUR7N`jJDvJ&~&}#Wrp4w2luoAN9A=j=2S%S~ejyAqy z#HlUOH-BxN*Ft_x*pX6^Qe0gw^dy(bGbIs$%WuAy`1?1;)2hI0ei5(?^MmmD@EOm#MxYlag ze=u3-uW!=a7M1od*K}dR-*$HpGP;`+7PyL#z3@6pWZsP5U2UP8L>j{r%Llxmc`Qj^C_LXCVWY-7iY3#pzB?ImpD7y_iH z6>W{azc3dN+SVpM4B-DTx-9I^!QZ=Lb5c?cp*z`7J*|&EF8{fTQ!$-;V4IVI^A$z! zAh0KINc8#8(5ARlb9i;E$`JqaH<5uP`T37)Y_V49Aioc!?8pw101S7pj3W=%3@@$4 zu1y3QsiX}$BcguiUQx<}e1?bT(;}CS%vI>8BB{x089a5~%hz6VM*i8dJU5&LIv00@ zZpDmg&nvSo4MwOKd!0-;bmipP@sOg^@-pdprX!>8E%66-@^3oWH{eglf7LE?;n-zO zrwe{)8AAP?ls)y5P%=$R_}QFID1I|H+!FVGVAR^ZR+07ky^gkpi$;*FTXiuk4|f&f z-GY;bvj`xH7SU)k$~AD2eLNfcc+JvzXW;{+^QQC3gh(3ZZHea@cx5>On)=U8?K3v8 z|NR_&q$WYw(pD}W2ST3349T?m(qebW#ppV;-2v|qE}AI4jS^_2)PqqWOH1Oju&$V@ zoUaurhD$0Y-j5LK(#gIDB5EupH-onT<`HJI_^hh-c|xBsB+5`RfqC1jaQ?~avsHL? zzWLNEmQE#scw}>C3-edoESPbG2Z8Y}uS+Y z5{xsBzZ)*A8mw3C9L-f+*e!sIt1Vtd-_{IYMbjeOthT%`NS=ss?W`_E!BHev;q3a! z=;PIzJ&z3vaUYkGwq5N4wo#0(S~orwo*Fh&_TKWQ5wr#VM%EDnhGkU#UV(0CO8Y41 zO(PoyC*+@q1UCvIk~?+p0QMSh>v!@6=Ut=iRLNrMBRgVp#Zww{4+`PIy@>|i8A?d; z43g4C?|EDcjkDo&iR%3(ffwJcX|q{%J;vmg+VA+7&915u>{Fz`A&d!$jXEzLK0rRy zR@9`w|39)H?~- zV8obamZ!Pl4!j8`o>%=-oL&%=mZQ^7Oq`&t2t;}t9>?Ip_)8;*MAvW?+1a5y?sQ8r z${O>HuP)SYS5!38i1BvJqP;&^cBI|@qhFi-voVH^U2{Wb{%`s%H86s7D=^Yb?Ebr^ zJfO7P z6Nzyw*{m{63_K1KqScW8V=j@herQjlgM}0U3S+Azc^-n)QEp;k3A*6dry6;x5~SP1V|K$5p@h->qkr z%Q@cOi2@~sM>MFWSAT*qW)$MNxm1h!1;wCY=tHjUQO=Ije%oj6y$Q+2^3@jn&4JUx z5&#FDHm#)lZKsgV%n`DG+SF(AVpSOuR$?)vjsQ~Ud1A?6?^-xiQ=$GjITyn@JoM2J zL)o=0(wYoAr`8#(X9`b64C^V!!cR?B^r;u3dpGD2O9-r4OB<#g8m>X-92xlH zgHgN60xhkGj;3@9tVc^L+k8`D<$#3SxbAQT=Ks`#^cDD3`Jpp!Du~^cVK~&usG8~L zu5cgp7{hy!NV+rIiuOhR2=`{6?iGcE#yOL%mdoohB_Q9k?bcB5Iop=JYB)sTtsjyA z$G_hSFi@0zZqR4=GDWVsN)CwUu%OnAy<($(x_SyLMhUG^UE0ICzs178^y_>uigB^C zS)`Kw0 z=N5U&NUHmwAu`NQ`0gU<{0dufiAbU(c>AP#JEY5r)u{*KWHk_ROS-AB1VI%tQj6zss z_P#OC^92KLfg?iiiO^#`6nGH9Ryn)`u|L}yv|AoE?$_fQ0yguA? z(FH!jwZ?JxMJmD{4aA3oz0g_zr4$f7VOcS^$y4WL9Ka8QSfWGWvKOnwxUPki`Ot1- z7lKFAKX&%2qIb#DX##Ywp%{$@pCdUH)lN<2eNy;Dz>$Ft%%4BwBFVEtcHHX=WEnlo zj_NsO$ufP|0188CPI|mbBUWdkUREmrm%1dM&m^5!=a-#L360UX93svC)fW2(bOFf! zghD1y0z9wARX%l=s>y^7WflT8+)%^umvZbX8o|q2OUSx*26Jk3#o32>8;%ECM?hm% z6`?s1%YE_YR)V08`UTY{8c}&(26A(yn;C1l6UN-Wud~C0e)*qNC9ff&Q3F*ML@49E z=3c;mO!TdUq%uW?bjI0cHoi!|GT+`CS~mFb-^e`I_TqQ*ib_r+1_jHgWwOCIJL3FE z{_s%~y4F}B_eO)aGPC;}Y_UIJPJnf8fE$8wWO2o1U6GM#@f5F0@A>dI4008?)^H2DK{0U=toEaKI1HdF^^>wiW3v1w&JSwh$RO{t z3N9E;;eUNncQb+m={~`*1O5h%CAYw8N(H8*VvqNs;L2kKWQcS*LKU zRplNIPZbMj4Z?y{Qvogl7!6^h#z=Hwxc5Z|5DQN4a7-T4h{1^$LxZJkyOIF9F5o~t zERP*{U<@3n$j}TWxkgb`-P+w##vpj4{#ynT9j)t<%*D?j@sQYbJPgKxe-nF4=!yQ3 zn$OL*PZ#%thFRD6dRE^%YE`vV^$ilYv{{XVc4Cus{q-sc*R>o0!uyx*laI2T25dD+ zoND>pd7(x@+A_gfZn5t#{nbUziPA%j=uULGvITVUUh#q{Fo4KloHC3~76RruMH^yQ zYM%-?$Yg{lg!xyw*iu~i(Z0q*5?|zb`nUYfN2{lu&5y-sHm(g4t~lq!|3v*PASM;b zX!0A3^;#L_^5uw9H@Uu3iDzNHpsYQ0lFhk^@Q>E3Wl7mesd~Os3=xV>phQDM`{TLt zzP`@2%KF<=z^!ST+@zUoE8VedYOm8N)cFr+lG9=apD(J(xEps4DJt3!9BBqRle57q zc$iBUB|)sGYZ{w~AL^%CerPimbW7?T1PQmfTQ@xa7g=({Jr-`Nlcplp@#8pinpl1vKbR^Y zp_oEt2ABI8;mjxIJpVyHXa5MvB$bp0<0YX>wUXSx-qovWmP#d7`!}#Dui+!yUEKoy)8#fA9x1y)lCT zjkdFzM0ugkzqq?S}u5<7dDZnSdf1ODu`=R-G~(^K_7ujuLh#<*}7Id)JH+;&&gWj{rI zVF`FJis=27mZwG0Xet;xjvGB=I?l1Y!9xdgqdcwg|JI~dcIQIB0%vjwH$_8~oDb!O zf%zy);-r3P#Ni{pCC)V6LaUHnt{YQRS4XWU9=UO+SO}nQYa|HnQ-YG~-iK{jLOEUy zhT>}_jqMCZ8x~c!|}?qNOAXr?e)G)kdd$zoi4 zgp`!J6SLNemp*TuTZ%af!K^~$dWFCViuLnxCRkS}jJKf|uQASdswQTw4rdUu3ioYi z^W9|Q?YQ%H2Ne-a@597;)`@rm`CY0lY<}SV!{qvwP592BYQ0nIRA;~a4S{>O@XMK4 zJol%WjLV{n~qbhwHdsQx2{-}KrjPg5(swAM^{%!jV#|Kp!cQXyC{k#G-O0yFEVfnN zKORLsTsDt~^}z}PKHfH4x&Z40y34`sF_0T?&yIlkEVm}me7r%7T)ec_BL0&SgG#(C z6FXRV<~N0+T}ACu)Bg0wN{=F51BWtr=`{l$*O4%JTyZ1(s?3>VW3V8~mTB4S18{pY z%kC0jwH6BInc#5_d{V+SsU@aAEwywHT)g zur-vuXS>B2v4!BjZJt*;-(qCE-$y;fsQN8^5PhH#kwiWnUfSTJZYc6OCSTxAls{XR zJ2%MQk91gVIhumaw%UfSOX6<)O5fk@a06^04HW1GF>*j}HOoeskEkp4+o`(WOAbV> zau0YDHyQUI$0-TL(mAE$zbZYquO9hrtMu|LR6=pKX7ydm!t+nK!!FTdaEi^rv3^8| z#}N53bk0B?9Y@_Y?89|;(uoPsi_Uj#D{BgzD(s@0*$so$I=c zHG}{yhz;Q#B+m^Gj<$n2aG{SNl{2u+h6Ikuv)N9!)$jBC?EHA=)C$f3IHCZ3A9Y&$ zRaeK_zc+X#4fb<2rztYBkSEp^`!=|0^u&rYce1+X_t0}KMyPuw4>YWC_`{QTW=rUU z7<=Pb__C6?Cl#*+mKLd>Js45dm}i&jk=si3Y0X5leOdtdYKpD&i7UPQrm}TtNr&g* zHJiNW0=;7ngnZtoJB~nK!Ms6`Ro3g3%#;J3_xDiL_^-ARDYRVk2;STcNMqo9A1VcZ zr-z>p)nPI$Pf%^YBJ#Fh?Zs*QzyYo_#Er);Yzae0n3<{vY#4Q&%z1;|02_k-1ULC% zwzQkfkUOF2-YA<|m3sI&Nw){}NeMI5qfp;~02VO;k$g!?=C5cYw~FX^rvbRHoQsg0@fMUg@c z_~I+@_?4O)=wU2#KdQMNCVxFGKGMN(h1K>mOMD9uV$5E@HOo$s4VXwpIv5P#DV%_cH&k&m& zf?+~lDKI_#RdQ-74gem9mf(rv2?ODZR#B@sJjr|_af`3>X_4j#Dmb6Iq;Cdfj1^%w z)heAoF@IdN$gtt6>2Ukb`KPzE87Msn%fbyl?x~{tE+Q17n+D!uyp$|D9r-Lu_A!yX z8aE^)DGDnL$ zrw-UbekoXhtxpVpEj0AD%-Igy#fUL9U*No6oM?T~hA=CfNL?~{h99Le*C|(n0S#&} zp1U!kYVppQ^qC{Qq;yd1Uv(%uZWO7R{z3MA9iB~Aut*S$D+@?Fnfor-lN(i%-$<)6 zeYd&t6@nN$>5=qj5#nL^he_ieIkJ35pqas!%Pi*2+I^!MqlZaPNf73mQ>d{>uLtvi zn(e|S{dHwn{x-2Bq7S9S@3YTz*-A?CRcaGFqeLEoN+tM45j=u?f8b>8iB+?CRf&3U zG3S&WmaqijRG8156SM|HTmur(NC3%Rs0DT&cjH~FhUIG#l#%3~SO0m=UL@6Tg-<1< z{KB&2nZtymt0%trRI@%}pkDXdZi)LU=~{jplN}PNVW~zCDdE8@@4cqXh+A-p<(RbX{0ss` zK6Ge2m1k3|48WbvkJ1C8*}nrMS5esSc~MGOMW1>VdNi+vH6GnRHLv#sWU*JkiX7R{ z1O~~~u(C)z);`>wrT`y~SZS84-<|=KB<~RQoCz68yLa|S<&=CwhT5>bagzh_(^JOP ziDh=R)qU9Do`w&F9ueHqQd6f`6hA%al=RSJ#V&%%qeBf}-@pv_X@Hy-mN@D>&r|Z^ z!*BDWNkhhqby)Wy=_Xo_{<_QY{6z46iyQlGN4a|JMwu~Z?`MLM`O;^ zJ{5Z~-pGjVd5#h6kMM-vZ)b4JR9(<)Uf(4m+>s=#=|$@}dKY#pIC!S5nL-?JQ!h45 z;<@t{_^G;gdkq)?KDiAr`;ToE^iXf0;Fqe7aAiQlXd+PY39Y4tQWHWfBS!X%1YoO$>q=A}7&dBtaawz7hYTy+z;KOvn|qauW5nZ-fs1#}FUCYT5fU zWcp#MlzFx+SDdgX6(M`JoM9ykBgxsLCp-lc&H|E7+}~-IS142+t#-p@T|nb$e-hDG z)7N+deE4x}Rt~FF#jKQmsX^2<*Ri8_JNcYfXGQcejR>sfx=`59U|(8mKOMh#?8aSFXG|QOB}pb02rAd$fi4S zKa=*i=UQTyQ62+$c}C(|X>k@;?8)?b$oBuNpCl;5F_nJLlUE=z(duv~BCh=fyXuI~MSVhb64O;2_(_uAXYe`P)#ZFc%Fg)U75$5i2F=2Lwf> z`KmYcF~fJ%OC~1gd8X@Y_NC|AOpo06AP1r3F|ZAp<;n&Sb0i!qMZv6~_YxRks{L*k z7|tI^%V!x4yp}0VVSOo;W0_)uij`6KeTfkZaClCX+i7)yw`OH{)&?A_BNPHu zjS8-qwmUhqB9Gs4pd&1_EK(RV6hO z%FkxQG)nQMed}UWJJw}jx65uS!3RKTK~!7M4h4E{^rQ9M8HxKsEHkh`pyE{xw!J*9 zcO8sxJq@A*$IZD-k^K z?vH^6_ZL`Mr&X13aRh6NnFdW7yXoGLCdRP-zhY zIoi9uZc%Vj#uFHYQe%bs^Qu7>Y+91SKRVbFxN<&-?8Lwc|cKAVE2V$)a}Wf)A%GQ!2+7-_f!*y&?lri zR*Bg~c-j$8S9PSV8@K&ORVK*m9X+7Wx@m36wo4_dC+INHGHF5}9lqwd)3AUn)j zlC_`sr^zmW?*r-_L$-9H!x_9p<8@#%`(d_IM~ay`vefp`1OrSyNjy0_h)TiEJEKYq zIC#~0>kA8vFYRr5$<)Zv08e*MR=OvqCzXx%k)p;^Ux>xCon-#?=VXDqNv_&Jo|LRj z@~1J19>{I{E;R0=-uLg3V}yWHR-4w?F>_|SSQ%3Y>Mg%UitDf_kdxv72a*8%LTh}( z6iK0q(mCSY!C$c&7E3R>k#95cR;&Ej0P`qT`LdTLiUpByqh=gxAsWL?06Lvcn--=0 zjy4ka{fjA$-|A~~^>aYp*W?hYt8VX2bU|j8$GDT?7yHWSA`QFw+^E!iwP$qELPKK1 z>)(rDNcI?cnT0imR^~!iSft?=t6p5oIyL>0%G&H3mU%CXebG=!FAd2D~3wD8BGmI8-2@qgDII!l+1|Hh&}w6mxiInu0wMUj0V zvTH>8F#|jnQ4Tg^I12t4%-Veb<4W7gqHF}2KV>Vu_5|^J%+;$)OI~Xt(;hZy z`NLDm*5I^*817U=&~U@%rIWtqxQGuKdEzOORUN>;wfpv=2R}b2`%S(vDXlsUDY1_` zEcKl1#w1cb2u)HGPhjTf9cKR|r-8ry`KPsAfM!~;Y_GcjvXuiHkz;tsS=J8ASew_5 zlTvPt)H2r!YRC$H?f3bCC#~VlhQ!y;gKXEg#sJ4DloWCb+Hl~a`J1;QF+=*$-+FL$$ zQdx5ur;$I6$;?(%M;z~BO2a9DYvP&=w}cVzABNuCUC7SoVCsKt_}rJXx_jTo+$;|j zsHxzWMQ`EmByw~gVF%aD3G}Q*N$dB36C+4tM>sU_^K0$A(nX2!4l>&5PfpNa$&W-7 z`)}pLL2(q2DC^4RLN|gLdT?xE`Wz)rsSs_9R4Lt9xqr-qWK#TS21&SMR3;03%X90=K6li=7QJV86y)K_Rr!L@|xOh}rE80em0NjMq;M zy4;(~(+xskua_TUht#uFbp(WL6?7&di=Q{knYPm)_moberpNUrx$t|z?s37N7_Tk$ zB17(C#ZjhRH#Dfav8zqx8U#gR)mVK)XAfY`ZcujeeG%mS$2VG)A5Y(n#_`0f_c*Yz(UW6oGs$^ zAB6JxurG!V$Tz9MK+oskHpU>_LIDsDx04qAW>%`Sfb8@M>dEUSqU8t;Wwq~HkLwe$ zhYRvx7hnm@LU*y3n%R|zVokZ;JYk}u~gE)1Npfr7}uP*IkUzs)kzbcA_{{D;? zLWGh-ris^>C5vt^R@IaD4Ob)6 z4_3nAz7(#wdgbXJn!KR5@sQ^Z&l;M&kft0ODg8NGgavYVQSi? zfnftkmMs4=RhJkPRb>QPo{<>_c!v+#&+8j>&!58xbNx_7ekZ8kQK704ghtP$QXv@lHwf+O^O@Ph{G!+s)kKpA7-MMA`dXavXWGE#3oRAEPT%8bU?=lT*;17O(Z7)CM2Xv`3p9k*|Ad;vexE=$eFA9r%^okDs~W)9-`X zyxW+d#v7pe<{T>2pUst8KKpIF^OS!Z=R5e8ctVz>dyJhyas8+ZU>cr&2^M?WYEIP# z-8wV|(H%GQ0}uQ%S3mTnU!De!VB`eD0)RwrP9Zr4^Z^tQ=W9~kzR|0lD98%nDc~^Q znCub;a0;X0hUBP4MmZCZK*lK&}g)e~serDqAH^t$-P)S&Lg?Bo||> z3(>vM>c!c_`y z7?L0Sq*F}|vgPg`U0}7EarcU_x@y6%3?nELxX+VD>qp*99jy*A`%I-PQ==8W|f(r&#Atd>p=z;KwNhjaU zU;U$<7v zi!ro*8%aA;)#IkfO|DtXXY%T=F?$HI_rpFr7CzXgk`r#cU-gnuQQcPrp7XD`pwZ5L zY2am>7$nI;)hqmczY?Dwa2AfHNyUPi5JPZsEcw1{tpdxQ_L6I#=~j%AOYpoO)Be zxq#Jhk_EJqwkNQ?;$pX0c&049pgaMpoyuZ=Cf3eYv)-tqu3d7xjP+l>rLc>T-5+P2 zNFai5q7ST95GXX!Nd3xF@x;H`FbRMXIvK`LpQR)x?WY6zt!x;|<4ii|9kc4n7b&wM z-9*9x&R9)UzuV1GRnL1vN5WD{w%5jV#pv@GrMKQytM_9_>uYMyU)FDtb2qTxWaD#U z>6F)6Mo80xwY4{fo^yUE>ew>EHIWS91G@|8rnb21cFn5=0rI4P5gU;`_4P_@ObtSb8#|j|H`4TydO&e(dgkR`@Zi)mEF6(k^quukp z`0{O;J#p)%@$+n>|MIC>!>cx{+Iqm^P{O&Dh!X`bDk!tkaXe1U*HtILd&Eg)e>2k|Gp3RqraL?h=O$*~ZBNM~RKzm$^*G3}f4(7!@u&3~jG?Uxa zz3iF~&(GkLbdMgO4f@bNXK&>d*{IUPB7=iBePL);Q+gx+`nTBd=>=md|) z?fH5_dq_W>WX-!6cYE0J;1GuGvqN@^_|<&I4zP$)W&QHMERhh!YtGCw~ zjyb&_qydNY0u1+Jv1se4jn zL$rSE@pDS#=BYxWX(iRYdhYo1N?R1A+n7k*3hnS|W;9KDLJbabkcMUjd>xfW9ntzA z%noXQwy24@e7YC=jN=)JQ?O)y+&_OfAtmH(4lvmPNPH(m<^>ds%R+_&%br8!`};t3 za&+;FT2ltU$+|6?e~S?oS?H*utgrSl_46g84MXP6>8$sH^{Ysa!)3T> znn>`9GcX88oQNe0*>I}bc|4)M^ds$i=~9jqSv3Q}hVA~2Q8;gh80a1Ku{GK8`|JO6 zhd4g8r8`l7?%-mA9HjGmVWn+OCdWT#od zmm^ifRD2-e+bJLnv&LQ7wM|o-x?cnH@jSPr{z@hyt#+!H1OYiNvM431H_cRO2JN_L zQPuU9+d?NPI=$D`5%>Kpkx^DjjM16%ME}mPQtuQm8#P>&3v-wa+~2B}=M7<}HRM zhROq3=~4~QvpHd`*8xp;TD(4maG}=Se)!z+>Oc2{_~=RzSAl>C?D<1D{ca+pL9kuh zx-ElnZ>X0~k~OYS{o`G44ybN^;bSWq_)~naZhXRnfI`Jc`{rwKKl1FWGmrinKrNV zJIZ;J9$#h|Kz$RKf@1fWwK@-FDbGLMyS_vI__G{$2^B4VXSyjjy5M4(*W_+br9>MKd%mcU3^^FBQ z&+52g*I{Jo_vfZkf&(PT6ts zt%sEsrAenukeCdvSyPd;>?zWKS1mp@)vw4l-n9@JGd3=nYoKle;LA@3yeIk(p~}ok zke9fYRPjF${LlU_tv=)kpKp4f;ECqnZbWO}s#^o^363N_~FY4m_xf&?9mk*0uHt$+`#1E9}ew3N&nx_^N!yS8J zH?H(kBGrUI5&reJwZMSLyx!&=mdKHn&UswXG(FeM{rJZqpWFoJfyUHrY=9D{G&Uy= z=_Pcj2ABIMbrrwXgmfGC5WKQv_BoBFjx)*z%G=ha=$Z;k-Rn?P_sv>=!y`}78wg!+ zA#ksU6u1`LetJupStfdXeN-5xH-rtQQT_l$oWB*On3pH1#YnY(9rrl zyt0?4HOfy(uH#$AYRN5e!J%jD*>2}&lOR3WA!I{&k&x zsPp&4a92X>%ksf|{yH4~coh}(;e?PS*kK~sSK6dz4=X>8@9YGdeP zZFQRM{UEC^j_1!DQzwR2#5D@gR=9Urlxz2FGY!A8>+2roMf@)8TahbY8|sEcC)o)I zRt5;i-0K=e#-&~efwuIl?z(EHDM$!i<#vuyJLGjIl~Jfj17WpihERf`j!?6m2Q#w$ zpR$%79?3s~JQ~OjJQ~D8Ee5;$5cW-T1rIG3H7?#SB z?X(1EI4WBUMbeL6ohb!j=!Qbt$PQ1a7e59cB?Q6x8)p9yM|;|I0=&C(zA3!=+FFvv z`hw3Sqq8RBn=P9hW3#O8eC|AI_d;y-xO}XQ23RM7EJrSO`GJ=32mHWvVw}xPJ1sUa zYyLJrX*JS}Qz}KjbtTtm1&jmLx5Rs@`ut%9k32ZH^0@Z9nBN-AU`k;DDXKTko8VD+6f?@Ts}0QmAkR zTuB{q+;9ISOY8=zxe*Wohf-k4)J33P6!XFuXchkis>Ts+)`c|JFR1VYSYoS$7V%K) zZs%6R)JB5V(8jW}23Of#x@Jk!Z0~-lpy!-NVpS;&5o{HvOb5T1$cA_!vsR2x1i)a_ zG3)c}*}1D7QUY(r*e~hJfxZ^)aH<9!q(6n6=?p}G*Oki#WhwG4e%)jIeor=I_VQfc zEQNzzl*HGXkjDqr=Sk?gCE~shyEMYW)xOMOr?7{sRpyrxX{*a9FH%Ew`tfYT$KxFK zY*n2l3f8=(&u7nC2u11RqLQ}Kc<_8pA>uXPisbjiBn?>1pCYmwaJDQ)m3W|XQLtV0f$15;;N-!1a*%h`ONjL<0`uRZo2&5 z*WTUED}c`-xGa7vM*giEtO=FLzX)I@?ZDNsU*K2WYUKivUj1 zoRSrBk^C(UGzU`4wkOBitn{g=5gWR#{?_2t2-ApDV^pihFy$}5`AWk^e{>(|*b~uy z{)Ji11he7EVQ13fth`;8F3!a|e#{F{kQ8m_?|L1ay}IArSG3?2)F6)F^M>bAN3a=H zHIUjTP+6vfry6BOld#b)AHCT>ZK(Je>!n&!yBXPWvyiS}e;F%79WuLr7#zBV3rgC7T3#7i}BB`yt=0Os=9T z=?@%yzj#*Pc|rm?1JVp1p9D}}+}^YR55?j$MGDo$N@;k;3EB2vZH$$f3N56b+8(0x z8i|=S?j;7f@%G50n5k)+UZ%6+U0Exil2brG)_;6M=%T%(7lAIlm1f;b81|tM22nEZ zOc82UoXX~Xt#0bL-5~$0)&Vbf0$|So7nl?U=+PSWQgcQH89%obYpNzFM`Y zxkAK&!-cQOPvwW=zprTlcN-o;&GC<&q~y;dIn2`hRaeFhEn``U>o8wN9h~dk|JW3o z$1oqKOt%!gQLDACF5y%j`wocDattI$VYmqaj>Xva!W`}ag}m-l(_X(qvOmlOc*zCt zD~wii0zAU}nl|KZkUfNYvt`0~ST97nm8km#K2bjcRt?&>&-Vb+zMms*irrAPHsy-X zI%A7;-bQN~dnhSEt$;zOER+Xc>R&^IrpN#t;F_nu2zY>Wzar5!x8_f^Q^crQT&`1K z?7p#BTx47%K*wG%s>n=z@ix$&2z|7^FB>})kZ60DZm)m@xb52Ou0I1|V(AlyKmEDt zN#k4n#PHb`^Jwm*<9#eI=u8I7?H7)orw-0~G>NN)I&?<+_E$0`*l9DRS|#=R3g|th z*1CIvL`UUzE|*cHV1?IlxnBF8ff`JH;d#Q?QCG^C+@#f@Od*Pdm4|Uf0ytpdpVdVvxs`Cn;mx<1qTlqqrdm zP|&I>)S0-}Q$s(Yg=eEY2279Jf2A87@%XQpwxLS-P}JoQ)g?;%LCi zMYb-GGT%eq-X%J@U;qzVNN|@BQ4G%+N!RORs<6!D|Jsp9$_E z+;|`>@dAY_xY*}out26`&!x{^bLL60rSWT7{oVUKBHPhj1DQl_glBx|TOvSZ0ari3 z@d}D?DCCC3K#%qn{>@kr96*{=>>_NIz1owY^J8;}?|X8+*^;c$_o;^U)sepqA=87Z zPzjJs`p(LSaABz9&5r>sYKKINaA|_$w=6TZ3@6yY+hu`%;1q`w+JX=| zg|k_GL)yyXCbh7EgqMEdVYOXLvzV{-4>Ee&yyrGA{;3Bbii-%|1QX9#&>mfXq{LD@ zu@`e=Q78w^S%s?WzNaL_q2@d2fI`qNUicRg_>_O!>oEkA>bS>cMv|}YSooKvrQFlN zLrRcJ3g|5$d*Q2=A9?^)lCVJnS}luhEr57k3C5Ak9K_Orw3Uf1%gpNMOecuL{!(;11 zf9dJHG_dD~mNM@)ha_)#^SyyAmn1H0D&5#%i<@cQ39%N=_%*OJ1lD`gd*WQ_NVP0o z!bZ-s=(mft+Bl#*X0S4Lu*DhLzk>wB27s1n?~@xNGnz>ubthWGqwJ;nT#WU`Mr zx*qP)j-qs()37oMGw-21!^8lLU_9~SOlHqs#M5|fQ`*gEt^1N7dAwb3((OaJ+u!uSr&aZRUU7)wbs)p+jSX@G zpp$SR%nB2aixX7UIV_9(F9-0wWgoRMhNMP}T!xJwAv++`x~)|FEwZLwuJiE&=aMdm zYGcf-AZC+0gEZt~gYd`3%l&Vy4)MGxk0Xbr>~Xz3jqa|(lh2xYh*KKPZpwTxgGB38 z=dggePY>VWk2&~L=Y70i`IzuMC~sR>oi|pEDnBn?#gBCqdcL$&>EWIZrjhl1WtQ*1 zML&B=m5d{(!@1R;lR&^>MBPjP!zXQ^2pm8U^WCt>9LprAD$Q7nCmD z`T&%GBUfG?kIdmuzu`cf+}qyjx>l9_Vc$t-n91ST+%B_R;H- z6w;esu90@;8Mpg92^qr+v-@7wfjvr#TbEscka3MuZ;^Px^xwh=6<;2|7wePQp6Gnz zDHfWpf2qLm&-q!o8O2|da5a(|&QDpFCj0J6ZuTAl{y3>0!W!dq8!EJWZsjxtZPaPgJ9=3lCB{(SaoWM^p4ep-5t4N0bX6zPO(l5 z-!y~`|J)>KJSyzXwDdV-S3dffLvUrj`mj`%wreP z(3)-fh=UGSFV#e@L}xte3+WEBp9b%H%N$kBM~oG9>g_KxBh~*Px<%&KbRebn>xsJM zbkvIh)JEpT8OI&U?VOhRDRVvtGLvIfWP<5XdzvRoM5s)FH43$V-y7$DLgDc-SAy$zvvzxiePM;y6bIheP8e3a4N~K^ zT!4Gjp?AJN#<;xyd!UNsu9cS!1{0|}@tnxt#G?{bX1duU@<*yBMr!QTui&>Btc;OCH+zL5F?)T+qI+^5Tx@tQfBF>l^`ART6Oxm@GV)7wVCm2EkqjYQ zj+yZv9|f!F5f^8X2$jAnRzpu=X7Lb;o4`x~T*Jpdv;WvV&$@bl)$Ly;Z(VZfFMI;~lZ8ctE|0 z*^Z$AW5d`3@1LuI8@J^XWnG#cR?~&&ZDfRexL>^6=ev+b=K_Dw1dlLCyCG3VK%T-G zYpS8slj;ofS`JUv%avfO=)X!opBN zMq(&CMKrThb}m_daEwA|TN)oH#-4SW!Qw#>n z0k0<#Gz5N9rQN6Be^tV_8vNHp<9mmeUg%Xb)KRekQ}mettk+ftooo2xr^|MnMbGZEsrhS=MAXUB9^zBnkVbsr_XuXEf#IIJhxU= z+~y296MmU3aFlxf89)|xZ*emO?$o}4epDoYM(@?n%uXQb&edCK*|s^SuyGtTgXxem z8?5yuVmRCUVc`Pt*q1!3Gs8#?Z~c;zdvZQO)_@BwQ{U+R%+bkI%-?9CYxXftaq{TW zX~UX-JoGe=pFX=#E?3>a^*Z2Im1euXH!4t}#2`FQkxDYPIs2_LCcTV7iyS`*96K-B zYAu~LRl|B7!PT(GN=zZa&dX5q)%B>RL!CPeOBn&CD`Zoh z;UsOej$N=B zulk*YF1L(AM~=;HbT%?vhDU3{PYv$Q=IUR25kJbBCfHk(Nk>c2R zgPH5$_@29I5dX!<)Q%>+lt%>^?3dFU+0a@_;6&?kQLP+qcptv%z3=vgV-Nl}{T&;q z`EKC#hJHSyj+`4dYt;W_3Rn}XSZ%B{_bB-grd1-5ra6MKv^8mVYd^Q*Fg*yC{-92w zbJHu=9aoAxpl)=c>Ne2eYGIJdKTpZ8*y%|SG86Y>YNF7H5t70h%3b6mx4ew2nzLrY zklSiWDT}Q@(Vr)iS>G+c$l|Tziz&L@+?*KQd$JYXX_1|5=i&GPrfT(BUjBeeuX*?roW`{j%hP}reoekTqvZ(|!28ti@>@Q2HL z%mlHP$dt@u>_Vt9hgIt*1}>HKNm% z>e3b(ONh>jwLupv%GbI8-Dn~bALr(aly}F=j4I7iFQVB=CGACe%aM=$|-6*r(oU5&Qc61~zJ&7DAc}%PH=h2*< zRFXoLb=eBI8$5xa*?z{1h`{J(kl~K_B?%V2A$F)F6Kx!m6}eU*oH!)Ef5U@ zAbDh)jrIh@it)`Tz#0MhW_hjw-?VNc9%>uAb#!}yMLPj?-e=m*sh-b+JENn1lgiuA zD<1Tk=@NV4XmvoMst9I7;fNWp#F@Rg?FH6bFVy3(ppg>-Eld#g`|WV%zRrK}<7$VA z+=^pL9Z2;0MXO2hfNf&Z=3fM!tOMOo8r|d}^e^AzT6$sZdOzE0>#9ISK1bmPgugVi zzZ-gKxqg*Ox;kxUys(`K|NMc`=kA6?`#T_emVqx!AaJwwdrd;=V?h^-f-oZGm7}na z6~RM_1zjpmFgqDv%W^GrpW@(sJpZ+GCS#Y|pjpP?)P}n`5`aT3LU1oA^4mCmu75pO zezh-i@oU`M4Der5W2=709V4pn!<4dN-}|ZZ4uojbQ>{C8=?@2nAM$aSg$>Z_WhyOZ z6h{lQ%BPN631JlCm1*yDX@Ijg zO{ZgzQr1hKh|1Gjyb!-IA@_$%NPS|;g+w^ie z_wZYJxEHxU<%4*tbNz&JRw_D(Ih!;!W{K!H%0jtOduhUDuBSC@czsVI+-o2|+o)ef zQsM&ZfP*I4Qaco*g59Hgs%YD8!O2X$DyprYHm6`uHm((=u5(^j!+6mSN48C+GecQJ zLTbM~qbRI8uXQBOjG~6r@ujEF zPGw*EU1oWYw1#Wh?5=mE?*tUv;idULUO8Zlb?&kt)-Syzd{7S#o6q4|&d-Hhp%hwaP>mOBJXeCN-3*%z$mO_+RYX*1K{_uKIV(9Ye~ z8!s@bCZwjz?yVq`SMNnn_9;ZzS{F{O_T+b(y*F(SZGR271y;N>XwLFiPiV0&Sh)et z5+`}-(7R5js8y=A-2#DgbgpI%#mw4Y_>RzzWamxq!&ABRgRJ+E^j+3xNxjd-9J`0g zLm130G9elAaaH%RkJ@{-SEsM6mMfF}Rcq6#SLoSq`~5a#$B2>5Z=Zc_(8e_9_0T8= zvTStPXt9}{H+k*4h3Kn?y(y=`=cETvON}F*NcMGh|$-iq{tTDIc zsVT5$CeA5p#R}Y*O?n2A1L=zx6dQrnX6s~3pu~gMF$L@yL|go!f=d*QF+P^}-Iq2e zjQ!0dkcUy1cMkZ;!9SkS)C3{-e0>Z5@AgnwyWdS*H7Y%;r+26T@X{=70#$GcFbc%& zV5oL!2?~O-(fA?xLETe}SBCzWBeuAS`R;X{%LAqf;px-7$M{nwd28~R@pvZRH?cwb z*!1i2&HrIy#~&E<`qBNS`rPOD2LAW)J^}0(cnWiR%VBA|pOIcD%No zeV$auF1(cY=au!Ma2})+QQy!JvcpahMC-KACcP3e-SUdvVf!1e;0axQLjS%055V8%35`<)fgNvl(mK?7l??eIo-)y5WXmFMp_G~ulnv6h9q4)?I!m%Cpt$>k*ZU;$ zV+`o~S)r>3>8`ziKw{uDm}FjeReOZa5ESNr=;5C#u znfn;r()#anjUczzr_2&x>I7b5EFdsSN6~J88EwVM@A1`aIp_G%erWVWkk-f>i-gVu$7VZ+a`P~j?8Ltt8@P?e&&nRdSZ`kAYUKp+-kX41-8UqT|GbnzH!U(0UCDq zokE!NROlf6(@2&|3AO6tt;f6nm{anj>(_hulEv%H zLMi(AW2N^Lsau@l^xiExBpX#%ox-ln1~2YAsLo8=k)3zQh9jBQZFFyye9{z7`J+O{ zW}{Me^hl%1fPiRUpJ2DJ@wb}Pj+v^%SJhUM%t5YDR zDEn`DGz{`;ni}RGWLJ0={BdBXbf1TIpTD>=YA9C`w_*5!1NEr#5VERW;X8D>vwPDE z4&R*Ld+zD93^lCBhCz+Z=zn2^qs>PjwDqsr8Yue8E$m6SG_Y;kMk_Znc_85UQBu{$ zg3;r7v*MhyoSiJ-xs7_>Dru4s9+ox0!;>aMc1Ye%f!v*MSn7o2j@$s7u%W>V?Lq$U z?%P4>i8*DUWXRkLUJ&Y}93w0M=Z+@KJ&Sn)9;{WdxBzD^JAoO|)L;2LRX6_Pl~^#e z$j}!CBJ8WdF4qCk310m?UVQI$G?64*@r7fIR&Q#Q_$skG_};p~+)iC*%2`1rp&{vS zzN4cifg8*a4sB@z?aoC}fx(EYSj8No0s3mwInU{ul2v%M^lg*FxC8+To$%*_*kH)r zhDJ{1FL&Y{A26#gh>@3E|NZL4!#nW(W}5u49{_X%4;$`jp@x^KBD>xG8iolo05V0=>4%Xyw}>G@{tlsJ2oY2vlM7f?z%?e7<=X(W|+ zE3wCP<5f-5df-cV95(bylDY5WjJ;50RGYpId}6$Xw!*HTIAlj6$wpvjZNCe9A31No z-gf7+rkb-Lmd>*mj~osrc^#Tr45KzN+UfI)7xHxW5)5sXho?BNS2h3MKvcw-y4)u- z#z$bdZuT3uXYMUA3-x1R=Z#WE5S_(;mUa52;H%eHbFg^#h?2QLRYm!HTd@3YuI(;3 z6jsy0XC6_QDDX=U@K=U|BM^&I@*W}TiqoYxzsG8d!_3@wwUX{IPvO77M6Y#!Toa#f zpKYYJA2j+n2{-obn4Th~uS-BjQSfSeedz95Pz$Qc+8~lwz|@c}-gT;0e4Ni1*5UpU zBM`bm<#MABuqJWBzoU%tZ_Wo?I%SClMt;EgNvGg5?B)QtL5Z3Ug%Vmo2>@qyVj{2TOe^n2@mXM$&JbU#^ zJh4+q_x0`3u67he#Mt$Fbds`n>i5(8=3Bu&pjVMhi(wUqU1#Nodt8>jBQV(NnoSz% z!Y}FHu?=Y!!O<#tp8lpqXaynAgq`Kvt~%HhTwBs*eBa6U9Lk0-|7ct@pj$;ldr0z? z-*s9(XrCGPoa5RZMJH`v#cS?K)zuE^#_-gN#eT6i-}w@dpchb`Wvcy~e?E+JL)PWp z{6r2zwLVQ}j(ZdCV&%JGv37OOXr)a}h~63Zcg^*~?RY?;ueX{!t=2h$;3}ey@!G@q z(rbed&4If&T*2kaW|-dw9FT@ynQ8{}$CEXB9kIzqQT(IgfC;do;%(aq`;U+2q#46Z zZaVI3DeyfD?Ck8{^{Zb~CLq&RR?~^P5xx}-{9JikQ7m&wsq~Y;B1inl(AX;FuM4Gi%Fk%WRsUPJ#7V2d^h5tPfc?`0$*A zoM_A4U)HqX40_Lnj+QBYR&WpK3@EHg0Z~aB`KUXq-Ld9hL|xI0b7`_7Dm(gcuv{MF z0xl~jO|bn)x`Wneo1Wz;oNIM7^Par+=mz9scPLNm-{UT`F&+(E#FZ5pqIvj;PlVUJvsGT99zcACjtND+_3 zZ(@o_m&q2nwW|MPn63(HIBDA&x?_mW{)Qspr``>oaZ{^(cf71De~24=bEQ0j3CRWm zK}dKSq8XwvXy?D3Evz~6ykyY0rD_p;15c1)1^E^i-R+a7TIb`LK-Zy>0J)B`vi?6{h?Y*^mn~X5Qkp}Ixs+iYMM~Q4)jYn^3sQVi{9>hW6J}SN||yM<_cAI zT$MB$lH96W>s%ZtieeV-l7#p4yzTEj;sG{^>M7dbAAFq_vx3){gY|He54d+A+Fw3^hebG zvznGtD`}IZeLrsfQkdmUIi@alPK$7q*2F@6K<2|VPxU+4o1(=a6p;7hb@b}z^8O;F zTSYc00{?qv8X6|!c{@)<>NGPskM7qz0_~J-xS=I5tze2oSm%ScvNf7aLpSZHLnuNW z*H^L}ek9Y3GZ8^g4}TFzp52f+mNyysvWG8%L8JQ&Y<5}%{EYlQoiW?ZN8Zvs_1WoN zD>ZyllCNw{ky?d?9WEk4k%33Aj`rA#!jO7Ms+loQYr#>zFh)Nq6KgfX-M&bTmcpH2 zh3XDJyYG+3Rzd0ecy;Ob+?IFm)I9oAybDbAYsnl&4cIt82cwbAiA9bFyPe&0vSn;U z_ta9RNP_rPAh*o}Y0Hve=S}H>)&hf2q6`rjeM9j>x@zNH(f`izi1b6k6edB~b;#LI zx@7j%`u+AZBb)3((q0K??ObEjPvb{?U3ybPm1IzE`KB606-B9c(9$`fsI}U6+n5;G ziJ{f-M{8;^?DUDT>cy@4pNa}gcB(sD8|Wchv6tgTM6tsJwmMtq&H}eF%@5umhwth} z9iGGylz|0&1A=muTvh6rpD0{@nZ`xg94!AJ%mo=!b(tZ?$+eIFu*P=5-7o2Q2z9^A z4K?e%cktMKF7dx|EDqyN(6QeX-{U>IB#NNz2`eR7?52==s3;VXs(^NdTIhE|ttMh; zrOO4qiOpzkQqc;qyDA%9QgIyS)sh1(vH!XENzs*rX8)|Q{sC--*CGs>yXyQt+UrXe zB-ekTL!N+TEqG3p{Y?rUPVN1C<#hRtiS{0E(RHFhOa0|m)AKsT-Z%4ix+AnRhru^w?{#uFYxLA6V5c0h`W;UUDgeHcwEAlpb=8i^k znFjI}MC%fPn#4lx%7`AZsLK(j3Fc9Bb#X$xF=zCNJ=Od2wg8xr&a;AUL!qm;C@A_J zc{M-yyCM%s3>w*7zAjZP+Mf$>jP@tGa7bwGX@AVB=|t zj+V3XM3EAH+r*k1$Fk^$nw3F`Mhk*^O~;_3a(9E=k!691+`i?^gz|@_F zr9hAy5+rdCeSRleq|Bs{i9@T(A8_4oqPd!DV5<36q>dI;CH#xT-o3 z2^PH!&M;m`C)7WCxgwh5Ao1)U;BWn(~`X2x0qE25~=U7!B#wI>kxtrX$ ztN^>&ifDevTf$|S=1iFwhhQyD8Ahzvn{a22Nr;+NiMvU~lP2Mmcng{GX;=yURl2;F zd$Nk><#=Gd){J6}RTMgxF|CX^>2dkFphf?qUbN#+>0kf5m&|_LRsr8>cXU=4 zl%48vWZaD`#N~R!62lCw1d|m55(_&AX&+QsJ+Y-jia)Cv#)(f3d>xvAN^|b`i@;{R zTO3?Hjn`F?EP~JSyKX5Ar<(iha{fo`r;YoTbHrNm9ADDRIV+L_O)VdeDig0!Zh!^? zU$t74nB%AB6+!6V?mBxt|DB|}hJoQ*s>%D5*4Bm}>HE+sW4`_$(+!6EZPaxYmH)#2 z*xpQ1RWqRn)r52bOGb=l1~Nr;gEBi~r}qhE`Knq5gb;_^IWw}QrcmeRi7T_BNY`4! z7Pqc5R}mWt)?KEIli?A%!06+XS_^*mrjeKPSa!TZ5&q_CPO$1NbQ*n@O&{*oRhmg79aQcqeOiuqb>m8_|H zY2MhSm2lU2D1e&P1$#aIwMJ^p{uJFW6OMgyC&H$S>6eD^E5qaMTZGqu5gOEaMT3Qh z3<>UJ)0#f@J}2H>kOMHnfr`IZ&=dbEw#QfQzn{ZDT4fUCB_|yD9g+49P@8fVi@(XD zWT)=tH)NBA;Kozj`?Nq0ji0Mjt?#mOjs`AnR>W-nPu6+w1z1d(7GiB(P5iW9ie`%w zd+=%m%48G1Y`r>N39GWGHIAqsd(w5}&cF1%&nmU1|2FUhMTq5|p zaSa^7VMofZxqNA@nZlu_znbxfD16EB@PQe$xdEJY}^@&8dd(yyN{+ z@s&@_PFCH+G`Er>G9yCD zRTpEN2yZZtl;nv1rh1+}Sgj;@ez``@dKjgN4)lr1(hS_h>#NA9#{T<3QPI8Is_Nfv zePHQ~&b)~-wA{Ti#A^7U1nlZ@7+InJF9UL4?e{*a+dXh5F!$GlxDur%NOo6A;aNi= zGadJ9E6%UBc0z{52(L8FHk{b$0h3x;FJZpF*Jod>n%+b$<=BzFWJn6m?2eI-*Zmr< z#+Zu1&HdBt5D_HzZrKfbFnT=s25DH>sPp+%MLJ^3B9vD4p&?9jk}>u5gO0F z?z8sWp0Y(gbdO;ljdQyYo*;QTnJ~DA<-d%0t3h`7mTRKRK}`s>(5dZU(qvVb^JU_` zqsQ*gL4?}MFef>5*W&#COFDsN&4%V z=9cwqaxJmaWan5H9PMmHE8B15JAT*^L%Z{KdP0W(3ka^q25*qI8J6Y3>QPp&Am_X~;uA%BN+dq{% z;m>(XFqSP87d0*o*IjS$>l^gBaYzIWnJh2;7`(^`z1|H~i0b$Q{OqJL@|M}WoM!Bt zAUfqpUMMpmt%cK*7V3d;`I{#RQVSpqQoPBSb@pBjXRs*y#~No3H07+?RQL>V@l7?g z5BK`s(sB*{a&@C=pKYgwr#%+bu}VvgAy$Ioa_szF3Y(NaCYJTEX07-0cWJO$6BT17 zduU2GYWo+#l!I4x&li#Bku0wLxUWXT^3a~JzcH;ICU~&&O3Mi=D(2>CZoe#-VmKgA zoV5nQMc(JP7+Cf}g~s+I6bmM-64}Mi`ot~_5uL~JDUtT*WTd8I4__5&)nY@QV#l8R z=u1w9b-Y>_t8%mHA>x6bC|0p6Cw+7Xj_?>H6a_D_WQ3SDTzMt3fppJnns1)zl1{fZ)0qm z4%ie9$+kAHdoZODQTj9RobDVr^E`@vE78H_9ZkwuB`pL(orI}9e+`BqU3`iEZCyCu zG9eiCb~G3Ipkjz*nb0-+^K-*1Q|jV`QZ~|qQ@H3)W=N^O#D6!{XRM5z>NEsnlxV9!Pw(sH#h$4?hg9oa?0iqXO2XMKa{(bV;d+q-Kf zgZCV;c7GlSv(v7CSp0_x>W9aU*PAc}ut5$9&Zy&Z^zUt94SYvge)0aa2Ber7Gt&7o zH9J_@v@8r-xJzRlkeBIuV)(ag@@2h+`Upl`%sLEcKWW88^HhoOHUA=1|@V?ESk=V@1&Yl8i0@K2lzDMRK?DcgG82)Cg$=FN9X&SksG2_%4D9=-Kku=(D1}ol zOW;}sSDd|#TD5njPw>G4$3YJn^=x}o%ZcqXxyJF!X>cvyRRGG-sw?RDlekF8fptE- zG-i$;LeJ(95TFRSf5^!D(=px9%L@XCC$FE6na_dxC*vvyIY!nYe*)26e1-@r{>}V1 z{yTNeDp8lDL$}@@3v3*LDveN!sy=|Rr-m@y4@eSqj^oE|q%$g?!MDp^YQS|O~ znH&wFkDqfYY0aQn5M9n zr}+XOoP{1$Z{2mn+hSkgLEls7js^bCWNH zzM&6lkc$O|S#`tgE0a9_OoJaU7(fvZ5~5N<0=%6+CkYy?(?PDuV_viM?)b!UD-BH+ z^6`}M7v)7t+O;9v}RX?DVCBAHfuhf#lIMrdJ#1}KvqM2QXy}c;kEdTzpnT> zy6XAs?sC@43()WOwx;Jl$=P8SNcmQH6yH=akH32A33~R41cNq2`zBbf!;}(&Er*1C z-5WcpZ7nvDN~B!X8Upg^-UdQ{DkD)dJDRCAMSrC*Auq#krHdufp%-B#@NC+p1p{*B17fYa>miIZU;Iz;( z!$eRZ>n3e}S#6vzD`^a>>hU^oWBuS-G)m}q*)yVtHj|&7F#O}z&d-3(dW~n=mJ$%zKOfyVnHev!-0^sEwJHRP2OOr*tnPM$X$tn z%-z9A`=VbeM!1_mKg?bIQhsw8ZP7Q5enWHp6r}m%sY1XD;sE>ZL7?vW8iD zfM`kC4~{lC=D&KN3fLd>OiQNJ#%@V#wr%bL}4H5>$fhI9y{V9Sy=awt?u)D zp02B{|89;EMWzN=I{;phsRk0ACjhyQo|=$9*Tkw%DQVTYa^`^-T4MTtM(Nu*GPPBG zCdOdaThi=3bsr*BWs<0cX&_AO309DYLH|Lz(p)@u)~w@Qh~|c(X&|8iT>9AZxbhjY zb?j?w2|*6_ngO^0bkIn3qJ`dU2u<RFm zOwT2ZP3=w8ah={urL&U_ORVgZjL3grz9p?pSFpU z+K7XU);e#mF7~2&I*mB`>w^FGxSk$;xFeN?8v&P|qB!*_OE3LaYwyy2%6n^$9Do1l z+(Ex`1z#im)RV7J5^M%nYr zlQW%|`iDEz!k>)z&Zgg+vpnpV=gxRjs`6wr&Y?1<@9^EAGslfnzKvK`gkCuJw-jrM z-Y~@wE$P0-9?ns7iTSl4Sfm)>%7rO&3N`Ud>+ck&V%1xQv#C_6gOGeoBhUK^y*@z* zF?{@<{2Qvv$_JS``2p22dW!8}PMLB>HZ6wqcMMPwWyF)q-Ttk`SJPoC=O)rhZRgmN zQK03=17c~!9;-jBZRmTZ-2lQf&N9>?f>+0ih`(Wg1_o#}pYE7*5cR8(24Hxt;7#xj;Bey=Vb`j?4YeMt;ZhHB`qDOQQe(Cl!9`r~X@8xLYKF zExFbCc(72wWCVe{bm1AExfSAyaXAcc@es-T{5KH-W1}EC&iw*^mgkdPEzLSuGP2;P zH5A#0`%Wh}j|ANt;caPo`NA*(GB25SmD@N1%k4%5hcKtxfWK|deg1jim+h|Lx2GiU zN+fiDRNT6U%Ey*>_zmC5jcw^xSKAr?liNYkQ2}LIKIQ69iRq-ovQueAq-mx*FX#%W zXRN{=cV>^Ur4>_tN6ieL)fCU?8j<{1f$u&a40+l&vVV!V3!XNUZ-U~7U*k{4pOiju zz1w^oBI=6$C{Sp|U5v~-`92Y#o4t|UWd#b7e5(Y!yl)asZZUAs`c_TGjS2av2QzT? zKBc{+9ESGH27xoTH>D+%d)UuE_T%oP{f@p^l3u}+0^_<4P#3p*G5$=#fX<3A1=YsqsQIcY1CiA3e(Tt270AM#^`e@Q8eDqo zkqEl!Wag4PH)rfPiN+cD1jVJ>v8wT9H|%B9SM`1~y6Uqx&Ckt%(JSy#`{cw#uyB}f zriNxS+)e5*tnzNQU5&06tU*s|gwOq{-iLG|=>LRvmf`o5+;; z$6xf`&ST#1X&6553=Ko<6ONbcN-_cLKn{{})7G&kC?P0GWaKk%k8`w3oCv)O| zHi%G!oGe-hGX%hR{~*7nS6o&*rQ7B-IOM z2|pn6V`^)?nbJ520-42A4dyceEm?4aXyACC&VR08Ke**B2m=D21L=sy(yL#&Rh{8; zug1*(6wInwA*T{5=orG8AXRH3kx?%D56di6z)(TtSgXMB6SreaL&I@*LpyJdQ6e2d#jwrHuy9oUqAEv0L6lh7zaZ=HBqS*T zy8hp?5@f1BC#L72Z9nPPWf#!|GklMgjb$FeR@J{SRJhhaZN1`SGGZ%F56CthzpGTN z>c6tfK)J$QFV;vWmWpBBMsK6M zVl;z;TsFbk|Mv9vR5M2RQN1tI5A|;Zle-7GS0i{N6Q$8KVg&Rr>;(&PMnWv>w8Wty z#j}^S|_?hA7QT=q}G>O+$wxc29vX0&QI= zB09ow@!q^GS37g80IK)Agq1MOn|W!LNzw(vpgVA&5vlVHlrk z3A%t@gXqrR9O3#jRoAJ-btnW%>qIDu_2!yKP))1ewY%<30>0cC&NSd8R>Jj*X{3n6ouPsLTFxCW zo_P+6F83=IFktj?>v5XV|9%F6`*_ zK7uT?cuaqld=f2%V-ZayEM17&s(fw>H*-oL$y26H6mZi5gAUG2!Num0M)t%jY*i+j zLM@SUvoJ4WN3MEuATKT@tYdPB9}P2E?6%`Rvp2+{fPc}I_?SBo*(LwBz@-+a1lIqC z^TmEA=79b}=RBfsfJN#~IP+)?w=%g#81k0+=`MlZl!^1^$nB8< zFixBgUoEP63A!u$O*%}~AKiV1lq>sKK~r^BEb;VA9A)Kz!a|!ZfP`J1dzwD;PeU&G zL($hUQW0M^v=YW8xkQTi))2|%!Zs*;A%#v+#jJEh@R2&y=MlBUq%cYQ;L-*aGieiQPxM~{58mR+0X3nG zmX?WstF{-> zoU#~A7M&PF=D=#F>mWj`^7SOvb>(wd7 zlKCiMZlEGXqrN6B1B5AWVaR6r7`4rCOguB49GjL?o{K<#fpRUvMJ7EQbeok1dD&tv zsj&&XfpwBh?)QbWA0#PTN?xrehRJkuPn3Jf9Jo6U#F59Y%lhH&lRq(P*nP>n=I0U? z%U4KNNOhfpC?2O5z?dnu+S~J1FsK4}wdy`QKb4b1*h($>_DUA>m2>=^m?mq73N5e3 zWJ6IA9TJy7+HM8bB|CihR#zjB_qPpY-_XCV-p+})*LXlSSYLB9MT;(cH$1w!+Q_BD zqD1`xF&fcJQt_k>5V{l=mxyiUl1&D?ajVu636`C6A0B z%vsSv$7p<;u0e#?)ElIRqF8~V(&@h$1B7s!a2YEth-!D8!!Gmc2FoS1itxRY)k*Y zA|ZIBd+Bm^33i~1gbp!2s4n$NZ^z~=n{jAtz@Uw*#o zV$JXnGNHfkYl&AfoHo%h59}t>&`&?%+1@RbK&J?n#O7#F!*C}ou*zF+eLh2@EM$aG zKTHzADaY9!6NHH0v1XXFbf7_pF~{U%VqTduWsRA9mQL}~T6!6Oi{+w~mGRHiZu%6z zcZ_1{DB5O(Ix@YdD729NCUgq3>sxiq4rw|kdvj~c!|kJbou&Kjv8tVBVC2_yUrzRa zOZ+qcvtHjvnkhJQyGO3cOyXO|#M_OEAq>HB9~#dpg4m2aZX>iAOF@O1sD3!jf!=YQ z;Rg=M>D=7T*pJ5@;Lz$wSr%IXZ0-ZuK#s|k*Wr&#f^YX!U+4gk$3Fss3a*$bt~-PW z$PUdf1?*>AxKux|C1#y*W}MtvH#m7ORI}n^Q#wV;14>v&9$zS#+A!XIwSi$B3=!yq z+zfWRd5?ux)i_3+wni`YhVkfdhoK%8@_AgbF(FQ)i$ZkXrs`n-{2ODm(@nKzS|gkgMYp2BRT_-wgM~ct+g6jG9K8J8(jY4-*~x*m zBBy}d5i8d2R_N#Xu8k%^R8ITq+aQtpof=t^U@3HiJ16QHcIf|zfy5*kux{*=nb#ia zHqJ{r6$$v*pXXyT#=%y32dnstIMR9^ZAZ`D$6b-{O;GhU)j&2tNyiQx3y^ZkbH+6Kb21Vb;Mld_6?L0QAb|Hy1;m{r@q!$ zB>$&OP1xo1RnAxqE`oOf=4#xtX$yQO-M`v_u<&>!lR;4&j3U@jQe~K9qx7)p0Jwp3 zNOFlAHc3??El)QQw#cvf{%V_~oxOBI!sentXl@CjOJSiX&e`e{##<3sj{N<_)u6l$ zK(_4c@cx-$q>aEif{O~=<0Ha%>WkpqEsp55&eA|r0aWuc=skRd$G;lXa5-^SD$`#j z^3@+8sn47MC`1J;uQJpmoJ^i^YFW2->P}vg9n=5ff|YZ=OL)M!lKnjRlQpL^qninq zD4$Y*GX1$3k>~DB5!fk?fS*AYoC5<=pVXnxoy}aSTf!HId3C=iX$X-z$~6_w%h!<>nu=8;;%4^slF*}pO@(I3|r2sldj&{D1GV8hQb(P5uz zE%;hHJXK<7f!`1gCkBy+Qy?Fw<-M+)PibaHNg6?+D{_8hnf=1nJ+@zc0q`nH1Ms2~ zhgo-aB{l3Y^)K0fsm+JD*O4Z&O2@O(ih#u6J?ga83V$a28K%b)?sAM z8_7@I4#6eq&t2oa!w&Z1{l=-TKj$0-OEwe1vxm>})06KUZsUPpVtxY@7Iwt!4V53Y TJFvl?03L*fzIy33tHA#Ol#kwS literal 0 HcmV?d00001 diff --git a/public/wallets/nwc.png b/public/wallets/nwc.png new file mode 100644 index 0000000000000000000000000000000000000000..e44a3ce1b31715cde42c28c519b5694608436c93 GIT binary patch literal 36519 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 literal 0 HcmV?d00001 diff --git a/public/wallets/primal-dark.svg b/public/wallets/primal-dark.svg new file mode 100644 index 00000000..1cceae1f --- /dev/null +++ b/public/wallets/primal-dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/primal.svg b/public/wallets/primal.svg new file mode 100644 index 00000000..0d834047 --- /dev/null +++ b/public/wallets/primal.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/rizful-dark.png b/public/wallets/rizful-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..852aee0e823cbdbbcd635abcd3addfb5284e4aa4 GIT binary patch literal 3264 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 Rj^axK@Nk8=)H;Pc_%~{TfhPa} literal 0 HcmV?d00001 diff --git a/public/wallets/rizful.png b/public/wallets/rizful.png new file mode 100644 index 0000000000000000000000000000000000000000..030668b18fe09a20b85638825a0f691a8cfac354 GIT binary patch literal 4278 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 Lk9+05?@#{+D*k#_ literal 0 HcmV?d00001 diff --git a/public/wallets/shockwallet-dark.png b/public/wallets/shockwallet-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..494bef8946b27b131e4ccb90817a6beb3789f5fa GIT binary patch literal 26437 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

wJvwp!Bk$PjRsi8ZiVAYR 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 literal 0 HcmV?d00001 diff --git a/public/wallets/speed-dark.svg b/public/wallets/speed-dark.svg new file mode 100644 index 00000000..fb3dca86 --- /dev/null +++ b/public/wallets/speed-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/wallets/speed.svg b/public/wallets/speed.svg new file mode 100644 index 00000000..fb3dca86 --- /dev/null +++ b/public/wallets/speed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/wallets/strike-dark.png b/public/wallets/strike-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..66de91d983fc1271f8485d83262ba2a68a8828d8 GIT binary patch literal 2916 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 literal 0 HcmV?d00001 diff --git a/public/wallets/voltage-dark.svg b/public/wallets/voltage-dark.svg new file mode 100644 index 00000000..aa300a9e --- /dev/null +++ b/public/wallets/voltage-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/voltage.svg b/public/wallets/voltage.svg new file mode 100644 index 00000000..76ab40e6 --- /dev/null +++ b/public/wallets/voltage.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/wallets/wos-dark.svg b/public/wallets/wos-dark.svg new file mode 100644 index 00000000..a27b2910 --- /dev/null +++ b/public/wallets/wos-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/wos.svg b/public/wallets/wos.svg new file mode 100644 index 00000000..a27b2910 --- /dev/null +++ b/public/wallets/wos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/zbd-dark.png b/public/wallets/zbd-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a627576dcba8ecdeccd38e7d58274cace7989ab8 GIT binary patch literal 9383 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 p)QpTMt2+LeE};l6`Eg5F)Km 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 literal 0 HcmV?d00001 diff --git a/public/wallets/zbd.svg b/public/wallets/zbd.svg new file mode 100644 index 00000000..1ce092e3 --- /dev/null +++ b/public/wallets/zbd.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/zeus-dark.svg b/public/wallets/zeus-dark.svg new file mode 100644 index 00000000..5953e329 --- /dev/null +++ b/public/wallets/zeus-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/wallets/zeus.svg b/public/wallets/zeus.svg new file mode 100644 index 00000000..5953e329 --- /dev/null +++ b/public/wallets/zeus.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/styles/dnd.module.css b/styles/dnd.module.css new file mode 100644 index 00000000..3b51ef8d --- /dev/null +++ b/styles/dnd.module.css @@ -0,0 +1,31 @@ +.draggable { + cursor: grab; + transition: all 0.2s ease-out; + position: relative; +} + +.dragging { + cursor: grabbing; + opacity: 0.3; + z-index: 1000; +} + +.dragOver { + transform: scale(1.03); + box-shadow: 0 0 10px var(--bs-info); +} + +@media (max-width: 768px) { + .draggable { + /* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action */ + touch-action: none; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + } + + .dragging { + touch-action: none; + } +} \ No newline at end of file diff --git a/styles/log.module.css b/styles/logger.module.css similarity index 52% rename from styles/log.module.css rename to styles/logger.module.css index 12c15b90..6e7cf05c 100644 --- a/styles/log.module.css +++ b/styles/logger.module.css @@ -1,4 +1,4 @@ -.tableContainer { +.container { width: 100%; max-height: 60svh; overflow-y: auto; @@ -9,34 +9,56 @@ } @media screen and (min-width: 768px) { - .tableContainer { + .container { max-height: 70svh; } .embedded { - max-height: 30svh; + max-height: 25svh; } } -.tableRow { +.row { + display: flex; + gap: 0.5rem; font-family: monospace; color: var(--theme-grey) !important; /* .text-muted */ } -.timestamp { - vertical-align: top; - text-wrap: nowrap; - justify-self: first baseline; +.row:hover { + background-color: rgba(128, 128, 128, 0.1); } -.wallet { - vertical-align: top; - font-weight: bold; +.timestamp { + text-wrap: nowrap; + min-width: 20px; + text-align: right; } .level { font-weight: bold; - vertical-align: top; text-transform: uppercase; - padding-right: 0.5em; + min-width: 32px; +} + +.tag { + vertical-align: top; + font-weight: bold; +} + +.message { + word-break: break-word; +} + +.indicator { + margin-left: auto; +} + +.context { + flex-basis: 100%; + display: grid; + grid-template-columns: min-content auto; + column-gap: 0.5rem; + padding-left: 0.5rem; + padding-bottom: 0.5rem; } diff --git a/styles/wallet.module.css b/styles/wallet.module.css index 8c2501f3..f7912c17 100644 --- a/styles/wallet.module.css +++ b/styles/wallet.module.css @@ -16,91 +16,33 @@ } } -@media (max-width: 330px) { - .walletGrid { - grid-template-columns: 100%; - } - .walletGrid > * { - justify-self: center; - } -} - -.walletFilters { - grid-column: 1 / -1; - margin-left: 0.2rem; - user-select: none; -} - -.drag { - opacity: 33%; -} - -.drop { - box-shadow: 0 0 10px var(--bs-info); -} - .card { width: 160px; max-width: 100%; aspect-ratio: 160 / 180; + transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease; } .indicators { display: flex; align-items: center; column-gap: 0.2rem; - margin-left: auto; padding: 10px; + justify-content: flex-end; position: absolute; + width: 100%; top: 0; right: 0; } -.walletLogo { - max-width: 100%; - max-height: 40%; - margin: auto; - text-align: center; - font-size: 1.5rem; - line-height: 1; -} - -.walletBanner { - max-width: min(256px, 100vw); - max-height: 100px; - padding: 0 15px 1rem 15px; -} - -.badge { - color: var(--theme-grey) !important; - background: var(--theme-clickToContextColor) !important; - vertical-align: middle; - margin-bottom: 0.1rem; - margin-top: 0.1rem; - margin-right: 0.2rem; -} - -.receive { - color: #20c997 !important; -} - -.send { - color: var(--bs-primary) !important; -} - -.attach { - color: var(--bs-body-color) !important; - text-align: center; -} - -.attach svg { - fill: var(--bs-body-color) !important; - margin-left: 0.5rem; -} - .indicator { width: 14px; height: 14px; + background-color: var(--theme-toolbarHover) !important; +} + +.indicator.drag { + background-color: transparent !important; } .indicator.success { @@ -127,6 +69,53 @@ border: 1px solid var(--theme-toolbarActive); } +.walletLogo { + max-width: 100%; + max-height: 40%; + margin: auto; + font-size: 1rem; + line-height: 1; +} + +.walletBanner { + max-width: min(256px, 100vw); + max-height: 100px; + padding: 0 15px 1rem 15px; +} + +.attach { + color: var(--bs-body-color) !important; + text-align: center; +} + +.attach svg { + fill: var(--bs-body-color) !important; + margin-left: 0.5rem; +} + +.nav { + justify-content: center; + font-size: 110%; + gap: 0 0.5rem; +} + +.nav :global .active { + border-bottom: 2px solid var(--bs-primary); +} + +.form { + display: flex; + justify-content: center; + max-width: 740px; + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-top: 5rem; + margin-left: auto; + margin-right: auto; + flex-direction: column; +} + .separator { display: flex; align-items: center; @@ -150,3 +139,10 @@ .separator:not(:empty)::after { margin-left: .25em; } + +.passphrase { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + container-type: inline-size; +} diff --git a/svgs/lock-line.svg b/svgs/lock-line.svg new file mode 100644 index 00000000..c8f7d931 --- /dev/null +++ b/svgs/lock-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/README.md b/wallets/README.md index 92a13f08..18809820 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -1,245 +1,369 @@ # Wallets -Every wallet that you can see at [/wallets](https://stacker.news/wallets) is implemented as a plugin in this directory. - -This README explains how you can add another wallet for use with Stacker News. - -> [!NOTE] -> Plugin means here that you only have to implement a common interface in this directory to add a wallet. - -## Plugin interface - -Every wallet is defined inside its own directory. Every directory must contain an _index.js_ and a _client.js_ file. - -An index.js file exports properties that can be shared by the client and server. - -Wallets that have spending permissions / can pay invoices export the payment interface in client.js. These permissions are stored on the client.[^1] - -[^1]: unencrypted in local storage until we have implemented encrypted local storage. - -A _server.js_ file is only required for wallets that support receiving by exposing the corresponding interface in that file. These wallets are stored on the server because payments are coordinated on the server so the server needs to generate these invoices for receiving. Additionally, permissions to receive a payment are not as sensitive as permissions to send a payment (highly sensitive!). - -> [!NOTE] -> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via -> -> ```js -> import wallet from '@/wallets//client' -> ``` -> -> vs -> -> ```js -> import wallet from '@/wallets//server' -> ``` -> -> on the server. -> -> To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this: -> -> ```js -> export * from '@/wallets/' -> ``` -> -> If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build. - -> [!TIP] -> Don't hesitate to use the implementation of existing wallets as a reference. - -### index.js - -An index.js file exports the following properties that are shared by imports of this wallet on the server and wallet: - -- `name: string` - -This acts as an ID for this wallet on the client. It therefore must be unique across all wallets and is used throughout the code to reference this wallet. This name is also shown in the [wallet logs](https://stacker.news/wallet/logs). - -- `shortName?: string` - -This is an optional value. Set this to true if your wallet needs to be configured per device and should thus not be synced across devices. - -- `fields: WalletField[]` - -Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/wallets/lnbits](https://stacker.news/walletslnbits). - -- `card: WalletCard` - -Wallet cards are the components you can see at [/wallets](https://stacker.news/wallets). This property customizes this card for this wallet. - -- `validate: (config) => void` - -This is an optional function that's passed the final config after it has been validated. Validation is otherwise done on each individual field in `fields. This function can be used to implement additional validation logic. If the validation fails, the function should throw an error with a descriptive message for the user. - -This validation is triggered on save. - -- `walletType?: string` - -This field is only required if this wallet supports receiving payments. It must match a value of the enum `WalletType` in the database. - -- `walletField?: string` - -Just like `walletType`, this field is only required if this wallet supports receiving payments. It must match a column in the `Wallet` table. - -> [!NOTE] -> This is the only exception where you have to write code outside this directory for a wallet that supports receiving: you need to write a database migration to add a new enum value to `WalletType` and column to `Wallet`. See the top-level [README](../README.md#database-migrations) for how to do this. - -#### WalletField - -A wallet field is an object with the following properties: - -- `name: string` - -The configuration key. This is used by [Formik](https://formik.org/docs/overview) to map values to the correct input. This key is also what is used to save values in local storage or the database. For wallets that are stored on the server, this must therefore match a column in the corresponding table for wallets of this type. - -- `label: string` - -The label of the configuration key. Will be shown to the user in the form. - -- `type: 'text' | 'password'` - -The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords. - -- `validate: Yup.Schema | ((value) => void) | RegExp` - -This property defines how the value for this field should be validated. If a [Yup schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, it will be used. Otherwise, the value will be validated by the function or the RegExp. When using a function, it is expected to throw an error with a descriptive message if the value is invalid. - -The validate field is required. - -- `optional?: boolean | string = false` - -This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text. - -- `help?: string | { label: string, text: string }` - -If this property is set, a help icon will be shown to the user. On click, the specified text in Markdown is shown. If you additionally want to customize the icon label, you can use the object syntax. - -- `editable?: boolean = true` - -If this property is set to `false`, you can only configure this value once. Afterwards, it's read-only. To configure it again, you have to detach the wallet first. - -- `placeholder?: string = ''` - -Placeholder text to show an example value to the user before they click into the input. - -- `hint?: string = ''` - -If a hint is set, it will be shown below the input. - -- `clear?: boolean = false` - -If a button to clear the input after it has been set should be shown, set this property to `true`. - -- `autoComplete?: HTMLAttribute<'autocomplete'>` - -This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons. - -- `clientOnly?: boolean = false` - -If this property is set to `true`, this field is only available on the client. If the stacker has device sync enabled, this field will be encrypted before being synced across devices. Otherwise, the field will be stored only on the current device. - -- `serverOnly?: boolean = false` - -If this property is set to `true`, this field is only meant to be used on the server and is safe to sync across devices in plain text. - -If neither `clientOnly` nor `serverOnly` is set, the field is assumed to be used on both the client and the server and safe to sync across devices in plain text. - -#### WalletCard - -- `title: string` - -The card title. - -- `subtitle: string` - -The subtitle that is shown below the title if you enter the configuration form of a wallet. - -- `image: { src: string, ... }` - -The image props that will be used to show an image inside the card. Should contain at least the `src` property. - -### client.js - -A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client: - -- `testSendPayment: async (config, context) => Promise` - -`testSendPayment` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`. - -How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key. - -This function must throw an error if the configuration was found to be invalid. - -The `context` argument is an object. It makes the wallet logger for this wallet as returned by `useWalletLogger` available under `context.logger`. See [wallets/logger.js](../wallets/logger.js). - -- `sendPayment: async (bolt11: string, config, context) => Promise` - -`sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet. - -The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testSendPayment`. The function should return the preimage on payment success. - -> [!IMPORTANT] -> As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included: -> -> ```js -> // wallets//client.js -> export * from '@/wallets/' -> ``` -> -> where `` is the wallet directory name. - -> [!IMPORTANT] -> After you're done implementing the interface, you need to import this wallet in _wallets/client.js_ and add it to the array that is the default export of that file to make this wallet available across the code: -> -> ```diff -> // wallets/client.js -> import * as nwc from '@/wallets/nwc/client' -> import * as lnbits from '@/wallets/lnbits/client' -> import * as lnc from '@/wallets/lnc/client' -> import * as lnAddr from '@/wallets/lightning-address/client' -> import * as cln from '@/wallets/cln/client' -> import * as lnd from '@/wallets/lnd/client' -> + import * as newWallet from '@/wallets//client' -> -> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd] -> + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet] -> ``` - -### server.js - -A wallet that supports receiving must export the following properties in server.js which are only available if this wallet is imported on the server: - -- `testCreateInvoice: async (config, context) => Promise` - -`testCreateInvoice` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service). - -It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving. - -Again, like `testSendPayment`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testSendPayment`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client). - -- `createInvoice: async (invoiceParams, config, context) => Promise` - -`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `invoiceParams` is an object that contains the invoice parameters. These include `msats`, `description`, `descriptionHash` and `expiry`. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testCreateInvoice` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials. - - -> [!IMPORTANT] -> Don't forget to include the following line: -> -> ```js -> // wallets//server.js -> export * from '@/wallets/' -> ``` -> -> where `` is the wallet directory name. - -> [!IMPORTANT] -> After you're done implementing the interface, you need to import this wallet in _wallets/server.js_ and add it to the array that is the default export of that file to make this wallet available across the code: -> -> ```diff -> // wallets/server.js -> import * as lnd from '@/wallets/lnd/server' -> import * as cln from '@/wallets/cln/server' -> import * as lnAddr from '@/wallets/lightning-address/server' -> + import * as newWallet from '@/wallets//client' -> -> - export default [lnd, cln, lnAddr] -> + export default [lnd, cln, lnAddr, newWallet] -> ``` \ No newline at end of file +## How to add a new wallet + +**1. Insert a new row to the `WalletTemplate` table with which protocols it supports** + +Example: + +```sql +INSERT INTO "WalletTemplate" (name, "sendProtocols", "recvProtocols") +VALUES ( + 'PHOENIX', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['BOLT12']::"WalletRecvProtocolName"[] +); +``` + +**2. Customize how the wallet looks on the client via [wallets/lib/wallets.json](/wallets/lib/wallets.json)** + +Example: + +```json +{ + // must be same name as wallet template + "name": "PHOENIX", + // name to show in client + "displayName": "Phoenix", + // image to show in client + "image": "/path/to/image.png", + // url (planned) to show in client + "url": "https://phoenix.acinq.co/" +} +``` + +_If the wallet supports a lightning address and the domain is different than the url, you can pass an object to `url`. Here is Zeus as an example:_ + +```json +{ + "templateId": 23, + "name": "ZEUS", + "displayName": "Zeus", + "image": "/wallets/zeus.svg", + "url": { + "wallet": "https://zeusln.com/", + // different domain for lightning address + "lud16Domain": "zeuspay.com" + } +}, +``` + +That's it! + +## How to add a new protocol + +**1. Update prisma.schema** + +- add enum value to `WalletProtocolName` enum +- add enum value to `WalletRecvProtocolName` or `WalletSendProtocolName` +- add table to store protocol config +- run `npx prisma migrate dev --create-only` + +

+Example + +```diff +diff --git a/prisma/schema.prisma b/prisma/schema.prisma +index 9a113797..12505333 100644 +--- a/prisma/schema.prisma ++++ b/prisma/schema.prisma +@@ -1199,6 +1199,7 @@ enum WalletProtocolName { + LNC + CLN_REST + LND_GRPC ++ BOLT12 + } + + enum WalletSendProtocolName { +@@ -1218,6 +1219,7 @@ enum WalletRecvProtocolName { + LN_ADDR + CLN_REST + LND_GRPC ++ BOLT12 + } + + enum WalletProtocolStatus { +@@ -1288,6 +1290,7 @@ model WalletProtocol { + walletRecvLightningAddress WalletRecvLightningAddress? + walletRecvCLNRest WalletRecvCLNRest? + walletRecvLNDGRPC WalletRecvLNDGRPC? ++ walletRecvBolt12 WalletRecvBolt12? + + @@unique(name: "WalletProtocol_walletId_send_name_key", [walletId, send, name]) + } +@@ -1429,3 +1432,12 @@ model WalletRecvLNDGRPC { + macaroon String + cert String? + } ++ ++model WalletRecvBolt12 { ++ id Int @id @default(autoincrement()) ++ createdAt DateTime @default(now()) @map("created_at") ++ updatedAt DateTime @default(now()) @updatedAt @map("updated_at") ++ protocolId Int @unique ++ protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) ++ offer String ++} +``` + +
+ +
+ +**2. Update migration file** + +- add required triggers (`wallet_to_jsonb` and `wallet_clear_vault` if send protocol) to migration file +- run `npx prisma migrate dev` + +
+Example + +```sql +-- AlterEnum +ALTER TYPE "WalletProtocolName" ADD VALUE 'BOLT12'; + +-- AlterEnum +ALTER TYPE "WalletRecvProtocolName" ADD VALUE 'BOLT12'; + +-- CreateTable +CREATE TABLE "WalletRecvBolt12" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "offer" TEXT NOT NULL, + + CONSTRAINT "WalletRecvBolt12_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvBolt12_protocolId_key" ON "WalletRecvBolt12"("protocolId"); + +-- AddForeignKey +ALTER TABLE "WalletRecvBolt12" ADD CONSTRAINT "WalletRecvBolt12_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- vvv Add trigger below manually vvv + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvBolt12" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + + +-- if protocol is for sending you also need to add the wallet_clear_vault trigger: +-- CREATE TRIGGER wallet_clear_vault +-- AFTER DELETE ON "WalletSendClinkDebit" +-- FOR EACH ROW +-- EXECUTE PROCEDURE wallet_clear_vault(); + +``` + +
+ +
+ +**3. Add protocol lib file** + +- add file to [wallets/lib/protocols](/wallets/lib/protocols) (see [JSDoc](/wallets/lib/protocols/index.js) for details) +- import in index.js file and add to default export + +
+Example + +```js +// wallets/lib/protocols/bolt12.js + +export default [ + { + // same as enum value we added + name: 'BOLT12', + displayName: 'BOLT12', + send: false, + fields: [ + { + name: 'offer', + type: 'text', + label: 'offer', + placeholder: 'lno...', + validate: offerValidator, + required: true, + } + ], + relationName: 'walletRecvBolt12' + } +] +``` + +```diff +diff --git a/wallets/lib/protocols/index.js b/wallets/lib/protocols/index.js +index 8caa5f52..58f5ab86 100644 +--- a/wallets/lib/protocols/index.js ++++ b/wallets/lib/protocols/index.js +@@ -7,6 +7,7 @@ import lnbitsSuite from './lnbits' + import phoenixdSuite from './phoenixd' + import blinkSuite from './blink' + import webln from './webln' ++import bolt12 from './bolt12' + + /** + * Protocol names as used in the database +@@ -44,5 +45,6 @@ export default [ + ...phoenixdSuite, + ...lnbitsSuite, + ...blinkSuite, +- webln ++ webln, ++ bolt12 + ] +``` + +
+ +
+ +**4. Add protocol method file** + +- if protocol to receive payments: Add file to [wallets/server/protocols](/wallets/server/protocols) (see [JSDoc](/wallets/server/protocols/index.js) for details) +- if protocol to send payments: Add file to [wallets/client/protocols](/wallets/client/protocols) (see [JSDoc](/wallets/client/protocols/index.js) for details) +- import in index.js file and add to default export + +
+Example + +```js +// wallets/server/protocols/bolt12.js + +// same as enum value we added +export const name = 'BOLT12' + +export async function createInvoice ({ msats, description, expiry }, config, { signal }) { + /* ... code to create invoice using protocol config ... */ +} + +export async function testCreateInvoice ({ url }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url }, + { signal } + ) +} +``` + +```diff +diff --git a/wallets/server/protocols/index.js b/wallets/server/protocols/index.js +index 26c292d9..3ac88ae1 100644 +--- a/wallets/server/protocols/index.js ++++ b/wallets/server/protocols/index.js +@@ -5,6 +5,7 @@ import * as clnRest from './clnRest' + import * as phoenixd from './phoenixd' + import * as blink from './blink' + import * as lndGrpc from './lndGrpc' ++import * as bolt12 from './bolt12' + + export * from './util' + +@@ -56,5 +57,6 @@ export default [ + clnRest, + phoenixd, + blink, +- lndGrpc ++ lndGrpc, ++ bolt12 + ] +``` + +
+ +
+ +**5. Update GraphQL code** + +- add GraphQL type +- add GraphQL type to `WalletProtocolConfig` union +- add GraphQL type to `WalletProtocolFields` fragment via spread operator (...) +- add GraphQL mutation to upsert protocol +- resolve GraphQL type in `mapWalletResolveTypes` function + +
+Example + +```diff +diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js +index 3c1fffd1..af3858a5 100644 +--- a/api/typeDefs/wallet.js ++++ b/api/typeDefs/wallet.js +@@ -38,6 +38,7 @@ const typeDefs = gql` + upsertWalletRecvLNDGRPC(walletId: ID, templateId: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, macaroon: String!, cert: String): WalletRecvLNDGRPC! + upsertWalletSendLNC(walletId: ID, templateId: ID, enabled: Boolean!, pairingPhrase: VaultEntryInput!, localKey: VaultEntryInput!, remoteKey: VaultEntryInput!, serverHost: VaultEntryInput!): WalletSendLNC! + upsertWalletSendWebLN(walletId: ID, templateId: ID, enabled: Boolean!): WalletSendWebLN! ++ upsertWalletRecvBolt12(walletId: ID, templateId: ID, enabled: Boolean!, networkTests: Boolean, offer: String!): WalletRecvBolt12! + removeWalletProtocol(id: ID!): Boolean + updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean + updateKeyHash(keyHash: String!): Boolean +@@ -111,6 +112,7 @@ const typeDefs = gql` + | WalletRecvLightningAddress + | WalletRecvCLNRest + | WalletRecvLNDGRPC ++ | WalletRecvBolt12 + + type WalletSettings { + receiveCreditsBelowSats: Int! +@@ -207,6 +209,11 @@ const typeDefs = gql` + cert: String + } + ++ type WalletRecvBolt12 { ++ id: ID! ++ offer: String! ++ } ++ + input AutowithdrawSettings { + autoWithdrawThreshold: Int! + autoWithdrawMaxFeePercent: Float! +diff --git a/wallets/client/fragments/protocol.js b/wallets/client/fragments/protocol.js +index d1a65ff4..138d1a62 100644 +--- a/wallets/client/fragments/protocol.js ++++ b/wallets/client/fragments/protocol.js +@@ -109,3 +109,11 @@ export const UPSERT_WALLET_SEND_WEBLN = gql` + } + } + ` ++ ++export const UPSERT_WALLET_RECEIVE_BOLT12 = gql` ++ mutation upsertWalletRecvBolt12($walletId: ID, $templateId: ID, $enabled: Boolean!, $networkTests: Boolean, $offer: String!) { ++ upsertWalletRecvBolt12(walletId: $walletId, templateId: $templateId, enabled: $enabled, networkTests: $networkTests, offer: $offer) { ++ id ++ } ++ } ++` +diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js +index c301f5c1..73d59e6d 100644 +--- a/wallets/client/fragments/wallet.js ++++ b/wallets/client/fragments/wallet.js +@@ -106,6 +106,10 @@ const WALLET_PROTOCOL_FIELDS = gql` + macaroon + cert + } ++ ... on WalletRecvBolt12 { ++ id ++ offer ++ } + } + } + ` +diff --git a/wallets/server/resolvers/util.js b/wallets/server/resolvers/util.js +index 0155a422..ced4b399 100644 +--- a/wallets/server/resolvers/util.js ++++ b/wallets/server/resolvers/util.js +@@ -19,6 +19,8 @@ export function mapWalletResolveTypes (wallet) { + return 'WalletRecvCLNRest' + case 'LND_GRPC': + return 'WalletRecvLNDGRPC' ++ case 'BOLT12': ++ return 'WalletRecvBolt12' + default: + return null + } +``` + +
diff --git a/wallets/blink/common.js b/wallets/blink/common.js deleted file mode 100644 index 75540115..00000000 --- a/wallets/blink/common.js +++ /dev/null @@ -1,67 +0,0 @@ -import { fetchWithTimeout } from '@/lib/fetch' -import { assertContentTypeJson, assertResponseOk } from '@/lib/url' - -export const galoyBlinkUrl = 'https://api.blink.sv/graphql' -export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' - -export const SCOPE_READ = 'READ' -export const SCOPE_WRITE = 'WRITE' -export const SCOPE_RECEIVE = 'RECEIVE' - -export async function getWallet ({ apiKey, currency }, { signal }) { - const out = await request({ - apiKey, - query: ` - query me { - me { - defaultAccount { - wallets { - id - walletCurrency - } - } - } - }` - }, { signal }) - - const wallets = out.data.me.defaultAccount.wallets - for (const wallet of wallets) { - if (wallet.walletCurrency === currency) { - return wallet - } - } - - throw new Error(`wallet ${currency} not found`) -} - -export async function request ({ apiKey, query, variables = {} }, { signal }) { - const method = 'POST' - const res = await fetchWithTimeout(galoyBlinkUrl, { - method, - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': apiKey - }, - body: JSON.stringify({ query, variables }), - signal - }) - - assertResponseOk(res, { method }) - assertContentTypeJson(res, { method }) - - return res.json() -} - -export async function getScopes ({ apiKey }, { signal }) { - const out = await request({ - apiKey, - query: ` - query scopes { - authorization { - scopes - } - }` - }, { signal }) - const scopes = out?.data?.authorization?.scopes - return scopes || [] -} diff --git a/wallets/blink/index.js b/wallets/blink/index.js deleted file mode 100644 index d870592c..00000000 --- a/wallets/blink/index.js +++ /dev/null @@ -1,71 +0,0 @@ -import { string } from '@/lib/yup' -import { galoyBlinkDashboardUrl } from '@/wallets/blink/common' - -export const name = 'blink' -export const walletType = 'BLINK' -export const walletField = 'walletBlink' - -export const fields = [ - { - name: 'apiKey', - label: 'api key', - type: 'password', - placeholder: 'blink_...', - clientOnly: true, - validate: string() - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Write' scopes when generating this API key.`, - requiredWithout: 'apiKeyRecv', - optional: 'for sending' - }, - { - name: 'currency', - label: 'wallet type', - type: 'text', - help: 'the blink wallet to use for sending (BTC or USD for stablesats)', - placeholder: 'BTC', - defaultValue: 'BTC', - clear: true, - autoComplete: 'off', - clientOnly: true, - validate: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['USD', 'BTC'], 'must be BTC or USD'), - optional: 'for sending', - requiredWithout: 'currencyRecv' - }, - { - name: 'apiKeyRecv', - label: 'receive api key', - type: 'password', - help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Receive' scopes when generating this API key.`, - placeholder: 'blink_...', - serverOnly: true, - validate: string() - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - optional: 'for receiving', - requiredWithout: 'apiKey' - }, - { - name: 'currencyRecv', - label: 'receive wallet type', - type: 'text', - help: 'the blink wallet to use for receiving (only BTC available)', - defaultValue: 'BTC', - clear: true, - autoComplete: 'off', - placeholder: 'BTC', - serverOnly: true, - validate: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['BTC'], 'must be BTC'), - optional: 'for receiving', - requiredWithout: 'currency' - } -] - -export const card = { - title: 'Blink', - subtitle: 'use [Blink](https://blink.sv/) for payments', - image: { src: '/wallets/blink.svg' } -} diff --git a/wallets/buttonbar.js b/wallets/buttonbar.js deleted file mode 100644 index 04b8c1b8..00000000 --- a/wallets/buttonbar.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Button } from 'react-bootstrap' -import CancelButton from '@/components/cancel-button' -import { SubmitButton } from '@/components/form' -import { isConfigured } from '@/wallets/common' - -export default function WalletButtonBar ({ - wallet, disable, - className, children, onDelete, onCancel, hasCancel = true, - createText = 'attach', deleteText = 'detach', editText = 'save' -}) { - return ( -
-
- {isConfigured(wallet) && wallet.def.requiresConfig && - } - {children} -
- {hasCancel && } - {isConfigured(wallet) ? editText : createText} -
-
-
- ) -} diff --git a/wallets/card.js b/wallets/card.js deleted file mode 100644 index a066884a..00000000 --- a/wallets/card.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Card } from 'react-bootstrap' -import styles from '@/styles/wallet.module.css' -import Plug from '@/svgs/plug.svg' -import Gear from '@/svgs/settings-5-fill.svg' -import Link from 'next/link' -import { isConfigured } from '@/wallets/common' -import DraggableIcon from '@/svgs/draggable.svg' -import RecvIcon from '@/svgs/arrow-left-down-line.svg' -import SendIcon from '@/svgs/arrow-right-up-line.svg' -import { useWalletImage } from '@/wallets/image' -import { useWalletStatus, statusToClass, Status } from '@/wallets/status' -import { useWalletSupport } from '@/wallets/support' - -export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) { - const image = useWalletImage(wallet) - const status = useWalletStatus(wallet) - const support = useWalletSupport(wallet) - - return ( - -
- {status.any !== Status.Disabled && } - {support.recv && } - {support.send && } -
- -
- {image - ? - : {wallet.def.card.title}} -
-
- - - {isConfigured(wallet) - ? <>configure - : <>attach} - - -
- ) -} diff --git a/wallets/client.js b/wallets/client.js deleted file mode 100644 index 8bd44698..00000000 --- a/wallets/client.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as nwc from '@/wallets/nwc/client' -import * as lnbits from '@/wallets/lnbits/client' -import * as lnc from '@/wallets/lnc/client' -import * as lnAddr from '@/wallets/lightning-address/client' -import * as cln from '@/wallets/cln/client' -import * as lnd from '@/wallets/lnd/client' -import * as webln from '@/wallets/webln/client' -import * as blink from '@/wallets/blink/client' -import * as phoenixd from '@/wallets/phoenixd/client' - -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] diff --git a/wallets/client/components/card.js b/wallets/client/components/card.js new file mode 100644 index 00000000..26a061a6 --- /dev/null +++ b/wallets/client/components/card.js @@ -0,0 +1,71 @@ +import { Card } from 'react-bootstrap' +import classNames from 'classnames' +import styles from '@/styles/wallet.module.css' +import Plug from '@/svgs/plug.svg' +import Gear from '@/svgs/settings-5-fill.svg' +import Link from 'next/link' +import RecvIcon from '@/svgs/arrow-left-down-line.svg' +import SendIcon from '@/svgs/arrow-right-up-line.svg' +import DragIcon from '@/svgs/draggable.svg' +import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus } from '@/wallets/client/hooks' +import { isWallet, urlify, walletDisplayName } from '@/wallets/lib/util' +import { Draggable } from '@/wallets/client/components' + +export function WalletCard ({ wallet, draggable = false, index, ...props }) { + const image = useWalletImage(wallet.name) + const status = useWalletStatus(wallet) + const support = useWalletSupport(wallet) + + const card = ( + +
+ {draggable && } + {support.receive && } + {support.send && } +
+ +
+ {image + ? + : {walletDisplayName(wallet.name)}} +
+
+ + + {isWallet(wallet) + ? <>configure + : <>attach} + + +
+ ) + + if (draggable) { + return ( + + {card} + + ) + } + + return card +} + +function WalletLink ({ wallet, children }) { + const support = useWalletSupport(wallet) + const sendRecvParam = support.send ? 'send' : 'receive' + const href = '/wallets' + (isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`) + `/${sendRecvParam}` + return {children} +} + +function statusToClass (status) { + switch (status) { + case WalletStatus.OK: return styles.success + case WalletStatus.ERROR: return styles.error + case WalletStatus.WARNING: return styles.warning + case WalletStatus.DISABLED: return styles.disabled + } +} diff --git a/wallets/client/components/draggable.js b/wallets/client/components/draggable.js new file mode 100644 index 00000000..c53677e8 --- /dev/null +++ b/wallets/client/components/draggable.js @@ -0,0 +1,44 @@ +import { useDndHandlers } from '@/wallets/client/context' +import classNames from 'classnames' +import styles from '@/styles/dnd.module.css' + +export function Draggable ({ children, index }) { + const { + handleDragStart, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleDragEnd, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + isBeingDragged, + isDragOver + } = useDndHandlers(index) + + return ( +
+ {children} +
+ ) +} diff --git a/wallets/client/components/forms.js b/wallets/client/components/forms.js new file mode 100644 index 00000000..fd634b34 --- /dev/null +++ b/wallets/client/components/forms.js @@ -0,0 +1,359 @@ +import { useEffect, useCallback, useMemo, createContext, useContext } from 'react' +import { Button, InputGroup, Nav } from 'react-bootstrap' +import Link from 'next/link' +import { useParams, usePathname } from 'next/navigation' +import { useRouter } from 'next/router' +import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components' +import { protocolDisplayName, protocolFields, protocolClientSchema, unurlify, urlify, isWallet, isTemplate, walletLud16Domain } from '@/wallets/lib/util' +import styles from '@/styles/wallet.module.css' +import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form' +import CancelButton from '@/components/cancel-button' +import { useWalletProtocolUpsert, useWalletProtocolRemove, useWalletQuery, TemplateLogsProvider } from '@/wallets/client/hooks' +import { useToast } from '@/components/toast' +import Text from '@/components/text' +import Info from '@/components/info' + +const WalletFormsContext = createContext() + +export function WalletForms ({ id, name }) { + // TODO(wallet-v2): handle loading and error states + const { data, refetch } = useWalletQuery({ name, id }) + const wallet = data?.wallet + + return ( + +
+ + {wallet && } + + {wallet && ( + + + + )} +
+
+ ) +} + +function WalletFormsProvider ({ children, wallet, refetch }) { + const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet]) + return ( + + {children} + + ) +} + +function useWalletRefetch () { + const { refetch } = useContext(WalletFormsContext) + return refetch +} + +function useWallet () { + const { wallet } = useContext(WalletFormsContext) + return wallet +} + +function WalletFormSelector () { + const sendRecvParam = useSendRecvParam() + const protocolParam = useWalletProtocolParam() + + return ( + <> + + {sendRecvParam && ( +
+
+ + {protocolParam && ( + + + + )} +
+
+ )} + + ) +} + +function WalletSendRecvSelector () { + const path = useWalletPathname() + const selected = useSendRecvParam() + + // TODO(wallet-v2): if you click a nav link again, it will update the URL + // but not run the effect again to select the first protocol by default + return ( + + ) +} + +function WalletProtocolSelector () { + const walletPath = useWalletPathname() + const sendRecvParam = useSendRecvParam() + const path = `${walletPath}/${sendRecvParam}` + + const protocols = useWalletProtocols() + const selected = useWalletProtocolParam() + const router = useRouter() + + useEffect(() => { + if (!selected && protocols.length > 0) { + router.replace(`/${path}/${urlify(protocols[0].name)}`, null, { shallow: true }) + } + }, [path]) + + if (protocols.length === 0) { + // TODO(wallet-v2): let user know how to request support if the wallet actually does support sending + return ( +
+ {sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported +
+ ) + } + + return ( + + ) +} + +function WalletProtocolForm () { + const sendRecvParam = useSendRecvParam() + const router = useRouter() + const protocol = useSelectedProtocol() + if (!protocol) return null + + // I think it is okay to skip this hook if the protocol is not found + // because we will need to change the URL to get a different protocol + // so the amount of rendered hooks should stay the same during the lifecycle of this component + const wallet = useWallet() + const upsertWalletProtocol = useWalletProtocolUpsert(wallet, protocol) + const toaster = useToast() + const refetch = useWalletRefetch() + + const { fields, initial, schema } = useProtocolForm(protocol) + + // create a copy of values to avoid mutating the original + const onSubmit = useCallback(async ({ ...values }) => { + const lud16Domain = walletLud16Domain(wallet.name) + if (values.address && lud16Domain) { + values.address = `${values.address}@${lud16Domain}` + } + + const upsert = await upsertWalletProtocol(values) + if (isWallet(wallet)) { + toaster.success('wallet saved') + refetch() + return + } + // we just created a new user wallet from a template + router.replace(`/wallets/${upsert.id}/${sendRecvParam}`, null, { shallow: true }) + toaster.success('wallet attached', { persistOnNavigate: true }) + }, [upsertWalletProtocol, toaster, wallet, router]) + + return ( + <> +
+ {fields.map(field => )} + + + + + + ) +} + +function WalletProtocolFormButtons () { + const protocol = useSelectedProtocol() + const removeWalletProtocol = useWalletProtocolRemove(protocol) + const refetch = useWalletRefetch() + const router = useRouter() + const wallet = useWallet() + const isLastProtocol = wallet.protocols.length === 1 + + const onDetach = useCallback(async () => { + await removeWalletProtocol() + if (isLastProtocol) { + router.replace('/wallets', null, { shallow: true }) + return + } + refetch() + }, [removeWalletProtocol, refetch, isLastProtocol, router]) + + return ( +
+ {!isTemplate(protocol) && } + cancel + {isWallet(wallet) ? 'save' : 'attach'} +
+ ) +} + +function WalletProtocolFormField ({ type, ...props }) { + const wallet = useWallet() + const protocol = useSelectedProtocol() + + function transform ({ validate, encrypt, editable, help, ...props }) { + const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint] + + const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text + const _help = help + ? ( + typeof help === 'string' + ? { label: null, text: help } + : ( + Array.isArray(help) + ? { label: null, text: parseHelpText(help) } + : { label: help.label, text: parseHelpText(help.text) } + ) + ) + : null + + const readOnly = !!protocol.config?.[props.name] && editable === false + + const label = ( +
+ {props.label} + {_help && ( + + {_help.text} + + )} + + {upperHint + ? {upperHint} + : (!props.required ? 'optional' : null)} + +
+ ) + + return { ...props, hint: bottomHint, label, readOnly } + } + + switch (type) { + case 'text': { + let append + const lud16Domain = walletLud16Domain(wallet.name) + if (props.name === 'address' && lud16Domain) { + append = @{lud16Domain} + } + return + } + case 'password': + return + default: + return null + } +} + +function useWalletPathname () { + const pathname = usePathname() + // returns /wallets/:name + return pathname.split('/').filter(Boolean).slice(0, 2).join('/') +} + +function useSendRecvParam () { + const params = useParams() + // returns only :send in /wallets/:name/:send + return ['send', 'receive'].includes(params.slug[1]) ? params.slug[1] : null +} + +function useWalletProtocolParam () { + const params = useParams() + const name = params.slug[2] + // returns only :protocol in /wallets/:name/:send/:protocol + return name ? unurlify(name) : null +} + +function useWalletProtocols () { + const wallet = useWallet() + const sendRecvParam = useSendRecvParam() + if (!sendRecvParam) return [] + + const protocolFilter = p => sendRecvParam === 'send' ? p.send : !p.send + return isWallet(wallet) + ? wallet.template.protocols.filter(protocolFilter) + : wallet.protocols.filter(protocolFilter) +} + +function useSelectedProtocol () { + const wallet = useWallet() + const sendRecvParam = useSendRecvParam() + const protocolParam = useWalletProtocolParam() + + const send = sendRecvParam === 'send' + let protocol = wallet.protocols.find(p => p.name === protocolParam && p.send === send) + if (!protocol && isWallet(wallet)) { + // the protocol was not found as configured, look for it in the template + protocol = wallet.template.protocols.find(p => p.name === protocolParam && p.send === send) + } + + return protocol +} + +function useProtocolForm (protocol) { + const wallet = useWallet() + const lud16Domain = walletLud16Domain(wallet.name) + const fields = protocolFields(protocol) + const initial = fields.reduce((acc, field) => { + // wallet templates don't have a config + let value = protocol.config?.[field.name] + + if (field.name === 'address' && lud16Domain && value) { + value = value.split('@')[0] + } + + return { + ...acc, + [field.name]: value || '' + } + }, { enabled: protocol.enabled }) + + let schema = protocolClientSchema(protocol) + if (lud16Domain) { + schema = schema.transform(({ address, ...rest }) => { + return { + address: address ? `${address}@${lud16Domain}` : '', + ...rest + } + }) + } + + return { fields, initial, schema } +} diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js new file mode 100644 index 00000000..76755a07 --- /dev/null +++ b/wallets/client/components/index.js @@ -0,0 +1,6 @@ +export * from './card' +export * from './draggable' +export * from './forms' +export * from './layout' +export * from './passphrase' +export * from './logger' diff --git a/wallets/client/components/layout.js b/wallets/client/components/layout.js new file mode 100644 index 00000000..c13f1259 --- /dev/null +++ b/wallets/client/components/layout.js @@ -0,0 +1,58 @@ +import Layout from '@/components/layout' +import { useWalletImage } from '@/wallets/client/hooks' +import { walletDisplayName } from '@/wallets/lib/util' +import Link from 'next/link' + +export function WalletLayout ({ children }) { + // TODO(wallet-v2): py-5 doesn't work, I think it gets overriden by the layout class + // so I still need to add it manually to the first child ... + return ( + + {children} + + ) +} + +export function WalletLayoutHeader ({ children }) { + return ( +

+ {children} +

+ ) +} + +export function WalletLayoutSubHeader ({ children }) { + return ( +
+ {children} +
+ ) +} + +export function WalletLayoutLink ({ children, href }) { + return ( + + {children} + + ) +} + +export function WalletLayoutImageOrName ({ name, maxHeight = '50px' }) { + const img = useWalletImage(name) + return ( +
+ {img + ? ( + {img.alt} + ) + : walletDisplayName(name)} +
+ ) +} diff --git a/wallets/client/components/logger.js b/wallets/client/components/logger.js new file mode 100644 index 00000000..08d787d4 --- /dev/null +++ b/wallets/client/components/logger.js @@ -0,0 +1,145 @@ +import { Button } from 'react-bootstrap' +import styles from '@/styles/logger.module.css' +import { useWalletLogs, useDeleteWalletLogs } from '@/wallets/client/hooks' +import { useCallback, useEffect, useState, Fragment } from 'react' +import { timeSince } from '@/lib/time' +import classNames from 'classnames' +import { ModalClosedError } from '@/components/modal' + +// TODO(wallet-v2): +// when we delete logs for a protocol, the cache is not updated +// so when we go to all wallet logs, we still see the deleted logs until the query is refetched + +export function WalletLogs ({ protocol, className }) { + const { logs, loadMore, hasMore, loading, clearLogs } = useWalletLogs(protocol) + const deleteLogs = useDeleteWalletLogs(protocol) + + const onDelete = useCallback(async () => { + try { + await deleteLogs() + clearLogs() + } catch (err) { + if (err instanceof ModalClosedError) { + return + } + console.error('error deleting logs:', err) + } + }, [deleteLogs, clearLogs]) + + const embedded = !!protocol + + return ( + <> +
+ clear logs + +
+
+ {logs.map((log, i) => ( + + ))} + {loading + ?
loading...
+ : logs.length === 0 &&
empty
} + {hasMore + ?
+ :
------ start of logs ------
} +
+ + ) +} + +export function LogMessage ({ tag, level, message, context, ts }) { + const [show, setShow] = useState(false) + + let className + switch (level.toLowerCase()) { + case 'ok': + case 'success': + level = 'ok' + className = 'text-success'; break + case 'error': + className = 'text-danger'; break + case 'warning': + level = 'warn' + className = 'text-warning'; break + default: + className = 'text-info' + } + + const filtered = context + ? Object.keys(context) + .filter(key => !['send', 'recv', 'status'].includes(key)) + .reduce((obj, key) => { + obj[key] = context[key] + return obj + }, {}) + : {} + + const hasContext = context && Object.keys(filtered).length > 0 + + const handleClick = () => { + if (hasContext) { setShow(show => !show) } + } + + const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' } + const indicator = hasContext ? (show ? '-' : '+') : <> + + // TODO(wallet-v2): show invoice context + + return ( + <> +
+ +
{`[${nameToTag(tag)}]`}
+
{level}
+
{message}
+
{indicator}
+
+ {show && hasContext && ( +
+ {Object.entries(filtered) + .map(([key, value], i) => { + return ( + +
{key}:
+
{value}
+
+ ) + })} +
+ )} + + ) +} + +function nameToTag (name) { + switch (name) { + case undefined: return 'system' + default: return name.toLowerCase() + } +} + +function TimeSince ({ timestamp }) { + const [time, setTime] = useState(timeSince(new Date(timestamp))) + + useEffect(() => { + const timer = setInterval(() => { + setTime(timeSince(new Date(timestamp))) + }, 1000) + + return () => clearInterval(timer) + }, [timestamp]) + + return
{time}
+} diff --git a/wallets/client/components/passphrase.js b/wallets/client/components/passphrase.js new file mode 100644 index 00000000..01a65ee7 --- /dev/null +++ b/wallets/client/components/passphrase.js @@ -0,0 +1,37 @@ +import React from 'react' +import { CopyButton } from '@/components/form' +import { QRCodeSVG } from 'qrcode.react' +import styles from '@/styles/wallet.module.css' + +export function Passphrase ({ passphrase }) { + const words = passphrase.trim().split(/\s+/) + return ( + <> +

+ Make sure to copy your passphrase now. +

+

+ This is the only time we will show it to you. +

+
+ +
+
+ {words.map((word, index) => ( +
+ {index + 1}. + + {word} +
+ ))} +
+
+ +
+ + ) +} diff --git a/wallets/client/components/search.js b/wallets/client/components/search.js new file mode 100644 index 00000000..9ef673e3 --- /dev/null +++ b/wallets/client/components/search.js @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react' +import { Form, InputGroup, Button } from 'react-bootstrap' +import SearchIcon from '@/svgs/search-line.svg' + +function fuzzySearch (query) { + return (text) => { + const pattern = query.toLowerCase().split('').join('.*') + const regex = new RegExp(pattern) + return regex.test(text.toLowerCase()) + } +} + +export function WalletSearch ({ setSearchFilter }) { + const [searchQuery, setSearchQuery] = useState('') + + const onChange = useCallback((e) => { + const query = e.target.value + setSearchQuery(query) + setSearchFilter(() => fuzzySearch(query)) + }, []) + + return ( +
+
+
+ + + + +
+
+
+ ) +} diff --git a/wallets/client/context/dnd.js b/wallets/client/context/dnd.js new file mode 100644 index 00000000..198810ed --- /dev/null +++ b/wallets/client/context/dnd.js @@ -0,0 +1,235 @@ +import { createContext, useContext, useCallback, useReducer, useState } from 'react' + +const DndContext = createContext(null) +const DndDispatchContext = createContext(null) + +export const DRAG_START = 'DRAG_START' +export const DRAG_ENTER = 'DRAG_ENTER' +export const DRAG_DROP = 'DRAG_DROP' +export const DRAG_END = 'DRAG_END' +export const DRAG_LEAVE = 'DRAG_LEAVE' + +const initialState = { + isDragging: false, + dragIndex: null, + dragOverIndex: null, + items: [] +} + +function useDndState () { + const context = useContext(DndContext) + if (!context) { + throw new Error('useDndState must be used within a DndProvider') + } + return context +} + +function useDndDispatch () { + const context = useContext(DndDispatchContext) + if (!context) { + throw new Error('useDndDispatch must be used within a DndProvider') + } + return context +} + +export function useDndHandlers (index) { + const dispatch = useDndDispatch() + const { isDragging, dragOverIndex, dragIndex } = useDndState() + const [isTouchDragging, setIsTouchDragging] = useState(false) + const [touchStartY, setTouchStartY] = useState(0) + const [touchStartX, setTouchStartX] = useState(0) + + const isBeingDragged = (isDragging || isTouchDragging) && dragIndex === index + const isDragOver = (isDragging || isTouchDragging) && dragOverIndex === index && dragIndex !== index + + const handleDragStart = useCallback((e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/html', e.target.outerHTML) + e.dataTransfer.setData('text/plain', index.toString()) + + // Remove browser default drag image by setting it to an invisible element + const invisibleElement = document.createElement('div') + invisibleElement.style.width = '1px' + invisibleElement.style.height = '1px' + invisibleElement.style.opacity = '0' + invisibleElement.style.position = 'absolute' + invisibleElement.style.top = '-9999px' + invisibleElement.style.left = '-9999px' + document.body.appendChild(invisibleElement) + e.dataTransfer.setDragImage(invisibleElement, 0, 0) + + // Remove the invisible element after a short delay + setTimeout(() => { + if (document.body.contains(invisibleElement)) { + document.body.removeChild(invisibleElement) + } + }, 100) + + dispatch({ type: DRAG_START, index }) + }, [index, dispatch]) + + const handleDragOver = useCallback((e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, []) + + const handleDragEnter = useCallback((e) => { + e.preventDefault() + dispatch({ type: DRAG_ENTER, index }) + }, [index, dispatch]) + + const handleDragLeave = useCallback((e) => { + e.preventDefault() + // Only clear if we're leaving the element (not entering a child) + if (!e.currentTarget.contains(e.relatedTarget)) { + dispatch({ type: DRAG_LEAVE }) + } + }, [dispatch]) + + const handleDrop = useCallback((e) => { + e.preventDefault() + const draggedIndex = parseInt(e.dataTransfer.getData('text/plain')) + if (draggedIndex !== index) { + dispatch({ type: DRAG_DROP, fromIndex: draggedIndex, toIndex: index }) + } + }, [index, dispatch]) + + const handleDragEnd = useCallback(() => { + dispatch({ type: DRAG_END }) + }, [dispatch]) + + // Touch event handlers for mobile + const handleTouchStart = useCallback((e) => { + if (e.touches.length === 1) { + const touch = e.touches[0] + setTouchStartX(touch.clientX) + setTouchStartY(touch.clientY) + setIsTouchDragging(false) + } + }, []) + + const handleTouchMove = useCallback((e) => { + if (e.touches.length === 1) { + const touch = e.touches[0] + const deltaX = Math.abs(touch.clientX - touchStartX) + const deltaY = Math.abs(touch.clientY - touchStartY) + + // Start dragging if moved more than 10px in any direction + if (!isTouchDragging && (deltaX > 10 || deltaY > 10)) { + setIsTouchDragging(true) + dispatch({ type: DRAG_START, index }) + } + + if (isTouchDragging) { + // Find the element under the touch point + const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY) + if (elementUnderTouch) { + const element = elementUnderTouch.closest('[data-index]') + if (element) { + const elementIndex = parseInt(element.dataset.index) + if (elementIndex !== index) { + dispatch({ type: DRAG_ENTER, index: elementIndex }) + } + } + } + } + } + }, [touchStartX, touchStartY, isTouchDragging, index, dispatch]) + + const handleTouchEnd = useCallback((e) => { + if (isTouchDragging) { + const touch = e.changedTouches[0] + const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY) + + if (elementUnderTouch) { + const element = elementUnderTouch.closest('[data-index]') + if (element) { + const elementIndex = parseInt(element.dataset.index) + if (elementIndex !== index) { + dispatch({ type: DRAG_DROP, fromIndex: index, toIndex: elementIndex }) + } + } + } + + setIsTouchDragging(false) + dispatch({ type: DRAG_END }) + } + }, [isTouchDragging, index, dispatch]) + + return { + handleDragStart, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleDragEnd, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + isBeingDragged, + isDragOver + } +} + +export function DndProvider ({ children, items, onReorder }) { + const [state, dispatch] = useReducer(dndReducer, { ...initialState, items }) + + const dispatchWithCallback = useCallback((action) => { + if (action.type !== DRAG_DROP) { + dispatch(action) + return + } + + const { fromIndex, toIndex } = action + if (fromIndex === toIndex) { + // nothing changed, just dispatch action but don't run onReorder callback + dispatch(action) + return + } + + const newItems = [...items] + const [movedItem] = newItems.splice(fromIndex, 1) + newItems.splice(toIndex, 0, movedItem) + onReorder(newItems) + }, [items, onReorder]) + + return ( + + + {children} + + + ) +} + +function dndReducer (state, action) { + switch (action.type) { + case DRAG_START: + return { + ...state, + isDragging: true, + dragIndex: action.index, + dragOverIndex: null + } + case DRAG_ENTER: + return { + ...state, + dragOverIndex: action.index + } + case DRAG_LEAVE: + return { + ...state, + dragOverIndex: null + } + case DRAG_DROP: + case DRAG_END: + return { + ...state, + isDragging: false, + dragIndex: null, + dragOverIndex: null + } + default: + return state + } +} diff --git a/wallets/client/context/hooks.js b/wallets/client/context/hooks.js new file mode 100644 index 00000000..a2c78a99 --- /dev/null +++ b/wallets/client/context/hooks.js @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLazyQuery } from '@apollo/client' +import { FAILED_INVOICES } from '@/fragments/invoice' +import { NORMAL_POLL_INTERVAL } from '@/lib/constants' +import useInvoice from '@/components/use-invoice' +import { useMe } from '@/components/me' +import { + useWalletsQuery, useWalletPayment, useGenerateRandomKey, useSetKey, useLoadKey, useLoadOldKey, + useWalletMigrationMutation, CryptoKeyRequiredError, useIsWrongKey +} from '@/wallets/client/hooks' +import { WalletConfigurationError } from '@/wallets/client/errors' +import { SET_WALLETS, WRONG_KEY, KEY_MATCH, NO_KEY, useWalletsDispatch } from '@/wallets/client/context' +import { useIndexedDB } from '@/components/use-indexeddb' + +export function useServerWallets () { + const dispatch = useWalletsDispatch() + const query = useWalletsQuery() + + useEffect(() => { + if (query.error) { + console.error('failed to fetch wallets:', query.error) + return + } + if (query.loading) return + dispatch({ type: SET_WALLETS, wallets: query.data.wallets }) + }, [query]) +} + +export function useKeyCheck () { +} + +export function useAutomatedRetries () { + const waitForWalletPayment = useWalletPayment() + const invoiceHelper = useInvoice() + const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' }) + const { me } = useMe() + + const retry = useCallback(async (invoice) => { + const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true }) + + try { + await waitForWalletPayment(newInvoice) + } catch (err) { + if (err instanceof WalletConfigurationError) { + // consume attempt by canceling invoice + await invoiceHelper.cancel(newInvoice) + } + throw err + } + }, [invoiceHelper, waitForWalletPayment]) + + useEffect(() => { + // we always retry failed invoices, even if the user has no wallets on any client + // to make sure that failed payments will always show up in notifications eventually + + if (!me) return + + const retryPoll = async () => { + let failedInvoices + try { + const { data, error } = await getFailedInvoices() + if (error) throw error + failedInvoices = data.failedInvoices + } catch (err) { + console.error('failed to fetch invoices to retry:', err) + return + } + + for (const inv of failedInvoices) { + try { + await retry(inv) + } catch (err) { + // some retries are expected to fail since only one client at a time is allowed to retry + // these should show up as 'invoice not found' errors + console.error('retry failed:', err) + } + } + } + + let timeout, stopped + const queuePoll = () => { + timeout = setTimeout(async () => { + try { + await retryPoll() + } catch (err) { + // every error should already be handled in retryPoll + // but this catch is a safety net to not trigger an unhandled promise rejection + console.error('retry poll failed:', err) + } + if (!stopped) queuePoll() + }, NORMAL_POLL_INTERVAL) + } + + const stopPolling = () => { + stopped = true + clearTimeout(timeout) + } + + queuePoll() + return stopPolling + }, [me?.id, getFailedInvoices, retry]) +} + +export function useKeyInit () { + const { me } = useMe() + + const dispatch = useWalletsDispatch() + const wrongKey = useIsWrongKey() + + useEffect(() => { + if (typeof window.indexedDB === 'undefined') { + dispatch({ type: NO_KEY }) + } else if (wrongKey) { + dispatch({ type: WRONG_KEY }) + } else { + dispatch({ type: KEY_MATCH }) + } + }, [wrongKey, dispatch]) + + const generateRandomKey = useGenerateRandomKey() + const setKey = useSetKey() + const loadKey = useLoadKey() + const loadOldKey = useLoadOldKey() + const [db, setDb] = useState(null) + const { open } = useIndexedDB() + + useEffect(() => { + if (!me?.id) return + let db + + async function openDb () { + db = await open() + setDb(db) + } + openDb() + + return () => { + db?.close() + setDb(null) + } + }, [me?.id, open]) + + useEffect(() => { + if (!me?.id || !db) return + + async function keyInit () { + try { + // TODO(wallet-v2): remove migration code + // and delete the old IndexedDB after wallet v2 has been released for some time + const oldKeyAndHash = await loadOldKey() + if (oldKeyAndHash) { + // return key found in old db and save it to new db + await setKey(oldKeyAndHash) + return + } + + // create random key before opening transaction in case we need it + // and because we can't run async code in a transaction because it will close the transaction + // see https://javascript.info/indexeddb#transactions-autocommit + const { key: randomKey, hash: randomHash } = await generateRandomKey() + + // run read and write in one transaction to avoid race conditions + const { key, hash } = await new Promise((resolve, reject) => { + const tx = db.transaction('vault', 'readwrite') + const read = tx.objectStore('vault').get('key') + + read.onerror = () => { + reject(read.error) + } + + read.onsuccess = () => { + if (read.result) { + // return key+hash found in db + return resolve(read.result) + } + + // no key found, write and return generated random key + const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash }, 'key') + + write.onerror = () => { + reject(write.error) + } + + write.onsuccess = (event) => { + // return key+hash we just wrote to db + resolve({ key: randomKey, hash: randomHash }) + } + } + }) + + await setKey({ key, hash }) + } catch (err) { + console.error('key init failed:', err) + } + } + keyInit() + }, [me?.id, db, generateRandomKey, loadOldKey, setKey, loadKey]) +} + +// TODO(wallet-v2): remove migration code +// ============================================================= +// ****** Below is the migration code for WALLET v1 -> v2 ****** +// remove when we can assume migration is complete (if ever) +// ============================================================= + +export function useWalletMigration () { + const { me } = useMe() + const { migrate: walletMigration, ready } = useWalletMigrationMutation() + + useEffect(() => { + if (!me?.id || !ready) return + + async function migrate () { + const localWallets = Object.entries(window.localStorage) + .filter(([key]) => key.startsWith('wallet:')) + .filter(([key]) => key.split(':').length < 3 || key.endsWith(me.id)) + .reduce((acc, [key, value]) => { + try { + const config = JSON.parse(value) + acc.push({ key, ...config }) + } catch (err) { + console.error(`useLocalWallets: ${key}: invalid JSON:`, err) + } + return acc + }, []) + + await Promise.allSettled( + localWallets.map(async ({ key, ...localWallet }) => { + const name = key.split(':')[1].toUpperCase() + try { + await walletMigration({ ...localWallet, name }) + window.localStorage.removeItem(key) + } catch (err) { + if (err instanceof CryptoKeyRequiredError) { + // key not set yet, skip this wallet + return + } + console.error(`${name}: wallet migration failed:`, err) + } + }) + ) + } + migrate() + }, [ready, me?.id, walletMigration]) +} diff --git a/wallets/client/context/index.js b/wallets/client/context/index.js new file mode 100644 index 00000000..c2d2ca8a --- /dev/null +++ b/wallets/client/context/index.js @@ -0,0 +1,7 @@ +import WalletsProvider from './provider' + +export * from './provider' +export * from './dnd' +export * from './reducer' + +export default WalletsProvider diff --git a/wallets/client/context/provider.js b/wallets/client/context/provider.js new file mode 100644 index 00000000..321e2e3d --- /dev/null +++ b/wallets/client/context/provider.js @@ -0,0 +1,81 @@ +import { createContext, useContext, useReducer } from 'react' +import walletsReducer, { Status } from './reducer' +import { useServerWallets, useKeyCheck, useAutomatedRetries, useKeyInit, useWalletMigration } from './hooks' +import { WebLnProvider } from '@/wallets/lib/protocols/webln' + +// https://react.dev/learn/scaling-up-with-reducer-and-context +const WalletsContext = createContext(null) +const WalletsDispatchContext = createContext(null) + +export function useWallets () { + const { wallets } = useContext(WalletsContext) + return wallets +} + +export function useTemplates () { + const { templates } = useContext(WalletsContext) + return templates +} + +export function useLoading () { + const { status } = useContext(WalletsContext) + return status === Status.LOADING_WALLETS +} + +export function useStatus () { + const { status } = useContext(WalletsContext) + return status +} + +export function useWalletsDispatch () { + return useContext(WalletsDispatchContext) +} + +export function useKey () { + const { key } = useContext(WalletsContext) + return key +} + +export function useKeyHash () { + const { keyHash } = useContext(WalletsContext) + return keyHash +} + +export default function WalletsProvider ({ children }) { + const [state, dispatch] = useReducer(walletsReducer, { + status: Status.LOADING_WALLETS, + wallets: [], + templates: [], + key: null, + keyHash: null + }) + + return ( + + + + + {children} + + + + + ) +} + +function WalletHooks ({ children }) { + useServerWallets() + useKeyCheck() + useAutomatedRetries() + useKeyInit() + + // TODO(wallet-v2): remove migration code + // ============================================================= + // ****** Below is the migration code for WALLET v1 -> v2 ****** + // remove when we can assume migration is complete (if ever) + // ============================================================= + + useWalletMigration() + + return children +} diff --git a/wallets/client/context/reducer.js b/wallets/client/context/reducer.js new file mode 100644 index 00000000..9b2d3733 --- /dev/null +++ b/wallets/client/context/reducer.js @@ -0,0 +1,73 @@ +import { isTemplate, isWallet } from '@/wallets/lib/util' + +// states that dictate if we show a button or wallets on the wallets page +export const Status = { + LOADING_WALLETS: 'LOADING_WALLETS', + NO_WALLETS: 'NO_WALLETS', + HAS_WALLETS: 'HAS_WALLETS', + PASSPHRASE_REQUIRED: 'PASSPHRASE_REQUIRED', + WALLETS_UNAVAILABLE: 'WALLETS_UNAVAILABLE' +} + +// wallet actions +export const SET_WALLETS = 'SET_WALLETS' +export const SET_KEY = 'SET_KEY' +export const WRONG_KEY = 'WRONG_KEY' +export const KEY_MATCH = 'KEY_MATCH' +export const NO_KEY = 'KEY_UNAVAILABLE' + +export default function reducer (state, action) { + switch (action.type) { + case SET_WALLETS: { + const wallets = action.wallets + .filter(isWallet) + .sort((a, b) => a.priority === b.priority ? a.id - b.id : a.priority - b.priority) + const templates = action.wallets + .filter(isTemplate) + .sort((a, b) => a.name.localeCompare(b.name)) + return { + ...state, + status: statusLocked(state.status) + ? state.status + : walletStatus(wallets), + wallets, + templates + } + } + case SET_KEY: + return { + ...state, + key: action.key, + keyHash: action.hash + } + case WRONG_KEY: + return { + ...state, + status: Status.PASSPHRASE_REQUIRED + } + case KEY_MATCH: + return { + ...state, + status: state.status === Status.LOADING_WALLETS + ? state.status + : walletStatus(state.wallets) + } + case NO_KEY: + return { + ...state, + status: Status.WALLETS_UNAVAILABLE + } + default: + return state + } +} + +function statusLocked (status) { + return [Status.PASSPHRASE_REQUIRED, Status.WALLETS_UNAVAILABLE].includes(status) +} + +function walletStatus (wallets) { + return wallets.length > 0 + ? Status.HAS_WALLETS + : Status.NO_WALLETS +} diff --git a/wallets/errors.js b/wallets/client/errors.js similarity index 100% rename from wallets/errors.js rename to wallets/client/errors.js diff --git a/wallets/client/fragments/index.js b/wallets/client/fragments/index.js new file mode 100644 index 00000000..3410da85 --- /dev/null +++ b/wallets/client/fragments/index.js @@ -0,0 +1,2 @@ +export * from './protocol' +export * from './wallet' diff --git a/wallets/client/fragments/protocol.js b/wallets/client/fragments/protocol.js new file mode 100644 index 00000000..65b08503 --- /dev/null +++ b/wallets/client/fragments/protocol.js @@ -0,0 +1,111 @@ +import { gql } from '@apollo/client' + +export const REMOVE_WALLET_PROTOCOL = gql` + mutation removeWalletProtocol($id: ID!) { + removeWalletProtocol(id: $id) + } +` + +export const UPSERT_WALLET_SEND_LNBITS = gql` + mutation upsertWalletSendLNbits($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: String!, $apiKey: VaultEntryInput!) { + upsertWalletSendLNbits(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LNBITS = gql` + mutation upsertWalletRecvLNbits($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!, $apiKey: String!) { + upsertWalletRecvLNbits(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_SEND_PHOENIXD = gql` + mutation upsertWalletSendPhoenixd($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: String!, $apiKey: VaultEntryInput!) { + upsertWalletSendPhoenixd(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_PHOENIXD = gql` + mutation upsertWalletRecvPhoenixd($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!, $apiKey: String!) { + upsertWalletRecvPhoenixd(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_SEND_BLINK = gql` + mutation upsertWalletSendBlink($walletId: ID, $templateName: ID, $enabled: Boolean!, $currency: VaultEntryInput!, $apiKey: VaultEntryInput!) { + upsertWalletSendBlink(walletId: $walletId, templateName: $templateName, enabled: $enabled, currency: $currency, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_BLINK = gql` + mutation upsertWalletRecvBlink($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $currency: String!, $apiKey: String!) { + upsertWalletRecvBlink(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, currency: $currency, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS = gql` + mutation upsertWalletRecvLightningAddress($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $address: String!) { + upsertWalletRecvLightningAddress(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, address: $address) { + id + } + } +` + +export const UPSERT_WALLET_SEND_NWC = gql` + mutation upsertWalletSendNWC($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: VaultEntryInput!) { + upsertWalletSendNWC(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_NWC = gql` + mutation upsertWalletRecvNWC($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!) { + upsertWalletRecvNWC(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_CLN_REST = gql` + mutation upsertWalletRecvCLNRest($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $socket: String!, $rune: String!, $cert: String) { + upsertWalletRecvCLNRest(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, socket: $socket, rune: $rune, cert: $cert) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LNDGRPC = gql` + mutation upsertWalletRecvLNDGRPC($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $socket: String!, $macaroon: String!, $cert: String) { + upsertWalletRecvLNDGRPC(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, socket: $socket, macaroon: $macaroon, cert: $cert) { + id + } + } +` + +export const UPSERT_WALLET_SEND_LNC = gql` + mutation upsertWalletSendLNC($walletId: ID, $templateName: ID, $enabled: Boolean!, $pairingPhrase: VaultEntryInput!, $localKey: VaultEntryInput!, $remoteKey: VaultEntryInput!, $serverHost: VaultEntryInput!) { + upsertWalletSendLNC(walletId: $walletId, templateName: $templateName, enabled: $enabled, pairingPhrase: $pairingPhrase, localKey: $localKey, remoteKey: $remoteKey, serverHost: $serverHost) { + id + } + } +` + +export const UPSERT_WALLET_SEND_WEBLN = gql` + mutation upsertWalletSendWebLN($walletId: ID, $templateName: ID, $enabled: Boolean!) { + upsertWalletSendWebLN(walletId: $walletId, templateName: $templateName, enabled: $enabled) { + id + } + } +` diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js new file mode 100644 index 00000000..5ef7fc64 --- /dev/null +++ b/wallets/client/fragments/wallet.js @@ -0,0 +1,259 @@ +import { gql } from '@apollo/client' + +const VAULT_ENTRY_FIELDS = gql` + fragment VaultEntryFields on VaultEntry { + id + iv + value + } +` + +export const CLEAR_VAULT = gql` + mutation ClearVault { + clearVault + } +` + +const WALLET_PROTOCOL_FIELDS = gql` + ${VAULT_ENTRY_FIELDS} + # need to use field aliases because of https://github.com/graphql/graphql-js/issues/53 + fragment WalletProtocolFields on WalletProtocol { + id + name + send + enabled + config { + __typename + ... on WalletSendNWC { + id + encryptedUrl: url { + ...VaultEntryFields + } + } + ... on WalletSendLNbits { + id + url + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendPhoenixd { + id + url + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendBlink { + id + encryptedCurrency: currency { + ...VaultEntryFields + } + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendWebLN { + id + } + ... on WalletSendLNC { + id + encryptedPairingPhrase: pairingPhrase { + ...VaultEntryFields + } + encryptedLocalKey: localKey { + ...VaultEntryFields + } + encryptedRemoteKey: remoteKey { + ...VaultEntryFields + } + encryptedServerHost: serverHost { + ...VaultEntryFields + } + } + ... on WalletRecvNWC { + id + url + } + ... on WalletRecvLNbits { + id + url + apiKey + } + ... on WalletRecvPhoenixd { + id + url + apiKey + } + ... on WalletRecvBlink { + id + currency + apiKey + } + ... on WalletRecvLightningAddress { + id + address + } + ... on WalletRecvCLNRest { + id + socket + rune + cert + } + ... on WalletRecvLNDGRPC { + id + socket + macaroon + cert + } + } + } +` + +const WALLET_TEMPLATE_FIELDS = gql` + fragment WalletTemplateFields on WalletTemplate { + # need to use field alias because of https://github.com/graphql/graphql-js/issues/53 + id: name + send + receive + protocols { + id + name + send + } + } +` + +const USER_WALLET_FIELDS = gql` + ${WALLET_PROTOCOL_FIELDS} + ${WALLET_TEMPLATE_FIELDS} + fragment WalletFields on Wallet { + id + name + priority + send + receive + protocols { + ...WalletProtocolFields + } + template { + ...WalletTemplateFields + } + } +` + +const WALLET_OR_TEMPLATE_FIELDS = gql` + ${USER_WALLET_FIELDS} + ${WALLET_TEMPLATE_FIELDS} + fragment WalletOrTemplateFields on WalletOrTemplate { + ... on Wallet { + ...WalletFields + } + ... on WalletTemplate { + ...WalletTemplateFields + } + } +` + +export const WALLETS = gql` + ${WALLET_OR_TEMPLATE_FIELDS} + query Wallets { + wallets { + ...WalletOrTemplateFields + } + } +` + +export const WALLET = gql` + ${WALLET_OR_TEMPLATE_FIELDS} + query Wallet($id: ID, $name: String) { + wallet(id: $id, name: $name) { + ...WalletOrTemplateFields + } + } +` + +export const REMOVE_WALLET = gql` + mutation removeWallet($id: ID!) { + removeWallet(id: $id) + } +` + +export const SET_WALLET_PRIORITIES = gql` + mutation SetWalletPriorities($priorities: [WalletPriorityUpdate!]!) { + setWalletPriorities(priorities: $priorities) + } +` + +export const UPDATE_WALLET_ENCRYPTION = gql` + mutation UpdateWalletEncryption($keyHash: String!, $wallets: [WalletEncryptionUpdate!]!) { + updateWalletEncryption(keyHash: $keyHash, wallets: $wallets) + } +` + +export const UPDATE_KEY_HASH = gql` + mutation UpdateKeyHash($keyHash: String!) { + updateKeyHash(keyHash: $keyHash) + } +` + +export const RESET_WALLETS = gql` + mutation ResetWallets($newKeyHash: String!) { + resetWallets(newKeyHash: $newKeyHash) + } +` + +export const DISABLE_PASSPHRASE_EXPORT = gql` + mutation DisablePassphraseExport { + disablePassphraseExport + } +` + +export const WALLET_SETTINGS = gql` + query WalletSettings { + walletSettings { + receiveCreditsBelowSats + sendCreditsBelowSats + proxyReceive + autoWithdrawMaxFeePercent + autoWithdrawMaxFeeTotal + autoWithdrawThreshold + } + } +` + +export const SET_WALLET_SETTINGS = gql` + mutation SetWalletSettings($settings: WalletSettingsInput!) { + setWalletSettings(settings: $settings) + } +` + +export const ADD_WALLET_LOG = gql` + mutation AddWalletLog($protocolId: Int!, $level: String!, $message: String!, $timestamp: Date!, $invoiceId: Int) { + addWalletLog(protocolId: $protocolId, level: $level, message: $message, timestamp: $timestamp, invoiceId: $invoiceId) + } +` + +export const WALLET_LOGS = gql` + query WalletLogs($protocolId: Int, $cursor: String) { + walletLogs(protocolId: $protocolId, cursor: $cursor) { + entries { + id + level + message + createdAt + wallet { + name + } + context + } + cursor + } + } +` + +export const DELETE_WALLET_LOGS = gql` + mutation DeleteWalletLogs($protocolId: Int) { + deleteWalletLogs(protocolId: $protocolId) + } +` diff --git a/wallets/client/hooks/crypto.js b/wallets/client/hooks/crypto.js new file mode 100644 index 00000000..d4016ee2 --- /dev/null +++ b/wallets/client/hooks/crypto.js @@ -0,0 +1,355 @@ +import { useCallback, useMemo } from 'react' +import { fromHex, toHex } from '@/lib/hex' +import { useMe } from '@/components/me' +import { useIndexedDB } from '@/components/use-indexeddb' +import { useShowModal } from '@/components/modal' +import { Button } from 'react-bootstrap' +import { Passphrase } from '@/wallets/client/components' +import bip39Words from '@/lib/bip39-words' +import { Form, PasswordInput, SubmitButton } from '@/components/form' +import { object, string } from 'yup' +import { SET_KEY, useKey, useKeyHash, useWalletsDispatch } from '@/wallets/client/context' +import { useDisablePassphraseExport, useUpdateKeyHash, useWalletEncryptionUpdate, useWalletReset } from '@/wallets/client/hooks' +import { useToast } from '@/components/toast' + +export class CryptoKeyRequiredError extends Error { + constructor () { + super('CryptoKey required') + this.name = 'CryptoKeyRequiredError' + } +} + +export function useLoadKey () { + const { get } = useIndexedDB() + + return useCallback(async () => { + return await get('vault', 'key') + }, [get]) +} + +export function useLoadOldKey () { + const { me } = useMe() + const oldDbName = me?.id ? `app:storage:${me?.id}:vault` : undefined + const { get } = useIndexedDB(oldDbName) + + return useCallback(async () => { + return await get('vault', 'key') + }, [get]) +} + +export function useSetKey () { + const { set } = useIndexedDB() + const dispatch = useWalletsDispatch() + const updateKeyHash = useUpdateKeyHash() + + return useCallback(async ({ key, hash }) => { + await set('vault', 'key', { key, hash }) + await updateKeyHash(hash) + dispatch({ type: SET_KEY, key, hash }) + }, [set, dispatch, updateKeyHash]) +} + +export function useEncryption () { + const defaultKey = useKey() + const defaultKeyHash = useKeyHash() + + const encrypt = useCallback( + (value, { key, hash } = {}) => { + const k = key ?? defaultKey + const h = hash ?? defaultKeyHash + if (!k || !h) throw new CryptoKeyRequiredError() + return _encrypt({ key: k, hash: h }, value) + }, [defaultKey, defaultKeyHash]) + + return useMemo(() => ({ + encrypt, + ready: !!defaultKey + }), [encrypt, defaultKey]) +} + +export function useDecryption () { + const key = useKey() + + const decrypt = useCallback(value => { + if (!key) throw new CryptoKeyRequiredError() + return _decrypt(key, value) + }, [key]) + + return useMemo(() => ({ + decrypt, + ready: !!key + }), [decrypt, key]) +} + +export function useRemoteKeyHash () { + const { me } = useMe() + return me?.privates?.vaultKeyHash +} + +export function useIsWrongKey () { + const localHash = useKeyHash() + const remoteHash = useRemoteKeyHash() + return localHash && remoteHash && localHash !== remoteHash +} + +export function useKeySalt () { + // TODO(wallet-v2): random salt + const { me } = useMe() + return `stacker${me?.id}` +} + +export function useShowPassphrase () { + const { me } = useMe() + const showModal = useShowModal() + const generateRandomKey = useGenerateRandomKey() + const updateWalletEncryption = useWalletEncryptionUpdate() + const toaster = useToast() + + const onShow = useCallback(async () => { + let passphrase, key, hash + try { + ({ passphrase, key, hash } = await generateRandomKey()) + await updateWalletEncryption({ key, hash }) + } catch (err) { + toaster.danger('failed to update wallet encryption: ' + err.message) + return + } + showModal( + close => , + { replaceModal: true, keepOpen: true } + ) + }, [showModal, generateRandomKey, updateWalletEncryption, toaster]) + + const cb = useCallback(() => { + showModal(close => ( +
+

+ The next screen will show the passphrase that was used to encrypt your wallets. +

+

+ You will not be able to see the passphrase again. +

+

+ Do you want to see it now? +

+
+ + +
+
+ )) + }, [showModal, onShow]) + + if (!me || !me.privates?.showPassphrase) { + return null + } + + return cb +} + +export function useSavePassphrase () { + const setKey = useSetKey() + const salt = useKeySalt() + const disablePassphraseExport = useDisablePassphraseExport() + + return useCallback(async ({ passphrase }) => { + const { key, hash } = await deriveKey(passphrase, salt) + await setKey({ key, hash }) + await disablePassphraseExport() + }, [setKey, disablePassphraseExport]) +} + +export function useResetPassphrase () { + const showModal = useShowModal() + const walletReset = useWalletReset() + const generateRandomKey = useGenerateRandomKey() + const setKey = useSetKey() + const toaster = useToast() + + const resetPassphrase = useCallback((close) => + async () => { + try { + const { key: randomKey, hash } = await generateRandomKey() + await setKey({ key: randomKey, hash }) + await walletReset({ newKeyHash: hash }) + close() + } catch (err) { + console.error('failed to reset passphrase:', err) + toaster.error('failed to reset passphrase') + } + }, [walletReset, generateRandomKey, setKey, toaster]) + + return useCallback(async () => { + showModal(close => ( +
+

Reset passphrase

+

+ This will delete all your sending credentials. Your credentials for receiving will not be affected. +

+

+ After the reset, you will be issued a new passphrase. +

+
+ + +
+
+ )) + }, [showModal, resetPassphrase]) +} + +const passphraseSchema = ({ hash, salt }) => object().shape({ + passphrase: string().required('required') + .test(async (value, context) => { + const { hash: expectedHash } = await deriveKey(value, salt) + if (hash !== expectedHash) { + return context.createError({ message: 'wrong passphrase' }) + } + return true + }) +}) + +export function usePassphrasePrompt () { + const showModal = useShowModal() + const savePassphrase = useSavePassphrase() + const hash = useRemoteKeyHash() + const salt = useKeySalt() + const showPassphrase = useShowPassphrase() + const resetPassphrase = useResetPassphrase() + + const onSubmit = useCallback((close) => + async ({ passphrase }) => { + await savePassphrase({ passphrase }) + close() + }, [savePassphrase]) + + return useCallback(() => { + showModal(close => ( +
+

Wallet decryption

+

+ Your wallets have been encrypted on another device. Enter your passphrase to use your wallets on this device. +

+

+ {showPassphrase && 'You can find the button to reveal your passphrase above your wallets on the other device.'} +

+

+ Press reset if you lost your passphrase. +

+
+ +
+
+ + + save +
+
+ +
+ )) + }, [showModal, savePassphrase, hash, salt]) +} + +export async function deriveKey (passphrase, salt) { + const enc = new TextEncoder() + + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: enc.encode(salt), + // 600,000 iterations is recommended by OWASP + // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + iterations: 600_000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const rawKey = await window.crypto.subtle.exportKey('raw', key) + const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) + const unextractableKey = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + + return { + key: unextractableKey, + hash + } +} + +async function _encrypt ({ key, hash }, value) { + // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure + // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm + // 12 bytes (96 bits) is the recommended IV size for AES-GCM + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(JSON.stringify(value)) + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encoded + ) + return { + keyHash: hash, + iv: toHex(iv.buffer), + value: toHex(encrypted) + } +} + +async function _decrypt (key, { iv, value }) { + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromHex(iv) + }, + key, + fromHex(value) + ) + const decoded = new TextDecoder().decode(decrypted) + return JSON.parse(decoded) +} + +export function useGenerateRandomKey () { + const salt = useKeySalt() + + return useCallback(async () => { + const passphrase = generateRandomPassphrase() + const { key, hash } = await deriveKey(passphrase, salt) + return { passphrase, key, hash } + }, [salt]) +} + +function generateRandomPassphrase () { + const rand = new Uint32Array(12) + window.crypto.getRandomValues(rand) + return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') +} diff --git a/wallets/client/hooks/image.js b/wallets/client/hooks/image.js new file mode 100644 index 00000000..f4379fdc --- /dev/null +++ b/wallets/client/hooks/image.js @@ -0,0 +1,18 @@ +import useDarkMode from '@/components/dark-mode' +import { walletDisplayName, walletImage } from '@/wallets/lib/util' + +export function useWalletImage (name) { + const [darkMode] = useDarkMode() + + const image = walletImage(name) + if (!image) return null + + let src = typeof image === 'string' ? image : image.src + const alt = typeof image === 'string' ? walletDisplayName(name) : image.alt + const hasDarkMode = typeof image === 'string' ? true : image.darkMode + + if (darkMode && hasDarkMode === false) return null + if (darkMode) src = src.replace(/\.([a-z]{3,4})$/, '-dark.$1') + + return { src, alt } +} diff --git a/wallets/client/hooks/index.js b/wallets/client/hooks/index.js new file mode 100644 index 00000000..80216fb8 --- /dev/null +++ b/wallets/client/hooks/index.js @@ -0,0 +1,8 @@ +export * from './payment' +export * from './image' +export * from './indicator' +export * from './prompt' +export * from './wallet' +export * from './crypto' +export * from './query' +export * from './logger' diff --git a/wallets/client/hooks/indicator.js b/wallets/client/hooks/indicator.js new file mode 100644 index 00000000..c9389679 --- /dev/null +++ b/wallets/client/hooks/indicator.js @@ -0,0 +1,7 @@ +import { useWallets, useLoading } from '@/wallets/client/context' + +export function useWalletIndicator () { + const wallets = useWallets() + const loading = useLoading() + return !loading && wallets.length === 0 +} diff --git a/wallets/client/hooks/logger.js b/wallets/client/hooks/logger.js new file mode 100644 index 00000000..c853304d --- /dev/null +++ b/wallets/client/hooks/logger.js @@ -0,0 +1,227 @@ +import { useMutation, useLazyQuery } from '@apollo/client' +import { ADD_WALLET_LOG, WALLET_LOGS, DELETE_WALLET_LOGS } from '@/wallets/client/fragments' +import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react' +import { Button } from 'react-bootstrap' +import { ModalClosedError, useShowModal } from '@/components/modal' +import { useToast } from '@/components/toast' +import { FAST_POLL_INTERVAL } from '@/lib/constants' +import { isTemplate } from '@/wallets/lib/util' + +const TemplateLogsContext = createContext({}) + +export function TemplateLogsProvider ({ children }) { + const [templateLogs, setTemplateLogs] = useState([]) + + const addTemplateLog = useCallback(({ level, message }) => { + // TODO(wallet-v2): Date.now() might return the same value for two logs + // use window.performance.now() instead? + setTemplateLogs(prev => [{ id: Date.now(), level, message, createdAt: new Date() }, ...prev]) + }, []) + + const clearTemplateLogs = useCallback(() => { + setTemplateLogs([]) + }, []) + + const value = useMemo(() => ({ + templateLogs, + addTemplateLog, + clearTemplateLogs + }), [templateLogs, addTemplateLog, clearTemplateLogs]) + + return ( + + {children} + + ) +} + +export function useWalletLoggerFactory () { + const { addTemplateLog } = useContext(TemplateLogsContext) + const [addWalletLog] = useMutation(ADD_WALLET_LOG) + + const log = useCallback(({ protocol, level, message, invoiceId }) => { + console[mapLevelToConsole(level)](`[${protocol.name}] ${message}`) + + if (isTemplate(protocol)) { + // this is a template, so there's no protocol yet to which we could attach logs in the db + addTemplateLog?.({ level, message }) + return + } + + return addWalletLog({ variables: { protocolId: Number(protocol.id), level, message, invoiceId, timestamp: new Date() } }) + .catch(err => { + console.error('error adding wallet log:', err) + }) + }, [addWalletLog, addTemplateLog]) + + return useCallback((protocol, invoice) => { + const invoiceId = invoice ? Number(invoice.id) : null + return { + ok: (message) => { + log({ protocol, level: 'OK', message, invoiceId }) + }, + info: (message) => { + log({ protocol, level: 'INFO', message, invoiceId }) + }, + error: (message) => { + log({ protocol, level: 'ERROR', message, invoiceId }) + }, + warn: (message) => { + log({ protocol, level: 'WARN', message, invoiceId }) + } + } + }, [log]) +} + +export function useWalletLogger (protocol) { + const loggerFactory = useWalletLoggerFactory() + return loggerFactory(protocol) +} + +export function useWalletLogs (protocol) { + const { templateLogs, clearTemplateLogs } = useContext(TemplateLogsContext) + + const [cursor, setCursor] = useState(null) + // if we're configuring a protocol template, there are no logs to fetch + const skip = protocol && isTemplate(protocol) + const [logs, setLogs] = useState(skip ? templateLogs : []) + + // if no protocol was given, we want to fetch all logs + const protocolId = protocol ? Number(protocol.id) : undefined + + const [fetchLogs, { called, loading, error }] = useLazyQuery(WALLET_LOGS, { + variables: { protocolId }, + skip, + fetchPolicy: 'network-only' + }) + + useEffect(() => { + if (skip) return + + const interval = setInterval(async () => { + const { data } = await fetchLogs({ variables: { protocolId } }) + const { entries: updatedLogs, cursor } = data.walletLogs + setLogs(logs => [...updatedLogs.filter(log => !logs.some(l => l.id === log.id)), ...logs]) + if (!called) { + setCursor(cursor) + } + }, FAST_POLL_INTERVAL) + + return () => clearInterval(interval) + }, [fetchLogs, called, skip]) + + const loadMore = useCallback(async () => { + const { data } = await fetchLogs({ variables: { protocolId, cursor } }) + const { entries: cursorLogs, cursor: newCursor } = data.walletLogs + setLogs(logs => [...logs, ...cursorLogs.filter(log => !logs.some(l => l.id === log.id))]) + setCursor(newCursor) + }, [fetchLogs, cursor, protocolId]) + + const clearLogs = useCallback(() => { + setLogs([]) + clearTemplateLogs?.() + setCursor(null) + }, [clearTemplateLogs]) + + return useMemo(() => { + return { + loading: skip ? false : (!called ? true : loading), + logs: skip ? templateLogs : logs, + error, + loadMore, + hasMore: cursor !== null, + clearLogs + } + }, [loading, skip, called, templateLogs, logs, error, loadMore, clearLogs]) +} + +function mapLevelToConsole (level) { + switch (level) { + case 'OK': + case 'INFO': + return 'info' + case 'ERROR': + return 'error' + case 'WARN': + return 'warn' + default: + return 'log' + } +} + +export function useDeleteWalletLogs (protocol) { + const showModal = useShowModal() + + return useCallback(async () => { + return await new Promise((resolve, reject) => { + const onClose = () => { + reject(new ModalClosedError()) + } + + showModal(close => { + const onDelete = () => { + resolve() + close() + } + + const onClose = () => { + reject(new ModalClosedError()) + close() + } + + return ( + + ) + }, { onClose }) + }) + }, [showModal]) +} + +function DeleteWalletLogsObstacle ({ protocol, onClose, onDelete }) { + const toaster = useToast() + const [deleteWalletLogs] = useMutation(DELETE_WALLET_LOGS) + + const deleteLogs = useCallback(async () => { + // there are no logs to delete on the server if protocol is a template + if (protocol && isTemplate(protocol)) return + + await deleteWalletLogs({ + variables: { protocolId: protocol ? Number(protocol.id) : undefined } + }) + }, [protocol, deleteWalletLogs]) + + const onClick = useCallback(async () => { + try { + await deleteLogs() + onDelete() + onClose() + toaster.success('deleted wallet logs') + } catch (err) { + console.error('failed to delete wallet logs:', err) + toaster.danger('failed to delete wallet logs') + } + }, [onClose, deleteLogs, toaster]) + + let prompt = 'Do you really want to delete all wallet logs?' + if (protocol) { + prompt = 'Do you really want to delete all logs of this protocol?' + } + + return ( +
+ {prompt} +
+ cancel + +
+
+ ) +} diff --git a/wallets/payment.js b/wallets/client/hooks/payment.js similarity index 74% rename from wallets/payment.js rename to wallets/client/hooks/payment.js index 1a5a038d..fd8c3592 100644 --- a/wallets/payment.js +++ b/wallets/client/hooks/payment.js @@ -1,23 +1,21 @@ import { useCallback } from 'react' -import { useSendWallets } from '@/wallets' -import { formatSats } from '@/lib/format' +import { useSendProtocols, useWalletLoggerFactory } from '@/wallets/client/hooks' import useInvoice from '@/components/use-invoice' import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, - WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError -} from '@/wallets/errors' -import { canSend } from './common' -import { useWalletLoggerFactory } from './logger' + WalletPaymentError, WalletError, WalletReceiverError +} from '@/wallets/client/errors' import { timeoutSignal, withTimeout } from '@/lib/time' import { useMe } from '@/components/me' +import { formatSats } from '@/lib/format' export function useWalletPayment () { - const wallets = useSendWallets() + const protocols = useSendProtocols() const sendPayment = useSendPayment() - const loggerFactory = useWalletLoggerFactory() const invoiceHelper = useInvoice() const { me } = useMe() + const loggerFactory = useWalletLoggerFactory() return useCallback(async (invoice, { waitFor, updateOnFallback } = {}) => { let aggregateError = new WalletAggregateError([]) @@ -29,25 +27,23 @@ export function useWalletPayment () { } // throw a special error that caller can handle separately if no payment was attempted - if (wallets.length === 0) { + if (protocols.length === 0) { throw new WalletsNotAvailableError() } - for (let i = 0; i < wallets.length; i++) { - const wallet = wallets[i] - const logger = loggerFactory(wallet) - - const { bolt11 } = latestInvoice + for (let i = 0; i < protocols.length; i++) { + const protocol = protocols[i] const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice) - const walletPromise = sendPayment(wallet, logger, latestInvoice) + const logger = loggerFactory(protocol, latestInvoice) + const paymentPromise = sendPayment(protocol, latestInvoice, logger) const pollPromise = controller.wait(waitFor) try { return await new Promise((resolve, reject) => { - // can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately. + // can't await payments since we might pay hold invoices and thus payments might not settle immediately. // that's why we separately check if we received the payment with the invoice controller. - walletPromise.catch(reject) + paymentPromise.catch(reject) pollPromise.then(resolve).catch(reject) }) } catch (err) { @@ -57,7 +53,7 @@ export function useWalletPayment () { if (!(paymentError instanceof WalletError)) { // payment failed for some reason unrelated to wallets (ie invoice expired or was canceled). // bail out of attempting wallets. - logger.error(message, { bolt11 }) + logger.error(message) throw paymentError } @@ -77,11 +73,11 @@ export function useWalletPayment () { if (paymentError instanceof WalletReceiverError) { // if payment failed because of the receiver, use the same wallet again // and log this as info, not error - logger.info('failed to forward payment to receiver, retrying with new invoice', { bolt11 }) + logger.info('failed to forward payment to receiver, retrying with new invoice') i -= 1 } else if (paymentError instanceof WalletPaymentError) { // only log payment errors, not configuration errors - logger.error(message, { bolt11 }) + logger.error(message) } if (paymentError instanceof WalletPaymentError) { @@ -89,8 +85,8 @@ export function useWalletPayment () { await invoiceHelper.cancel(latestInvoice) } - // only create a new invoice if we will try to pay with a wallet again - const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1 + // only create a new invoice if we will try to pay with a protocol again + const retry = paymentError instanceof WalletReceiverError || i < protocols.length - 1 if (retry) { latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) } @@ -105,7 +101,7 @@ export function useWalletPayment () { // if we reach this line, no wallet payment succeeded throw new WalletPaymentAggregateError([aggregateError], latestInvoice) - }, [wallets, invoiceHelper, sendPayment, loggerFactory]) + }, [protocols, invoiceHelper, sendPayment]) } function invoiceController (inv, isInvoice) { @@ -147,30 +143,21 @@ function invoiceController (inv, isInvoice) { } function useSendPayment () { - return useCallback(async (wallet, logger, invoice) => { - if (!wallet.config.enabled) { - throw new WalletNotEnabledError(wallet.def.name) - } - - if (!canSend(wallet)) { - throw new WalletSendNotConfiguredError(wallet.def.name) - } - - const { bolt11, satsRequested } = invoice - - logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 }) + return useCallback(async (protocol, invoice, logger) => { try { - const preimage = await withTimeout( - wallet.def.sendPayment(bolt11, wallet.config, { - logger, - signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) - }), + logger.info(`↗ sending payment: ${formatSats(invoice.satsRequested)}`) + await withTimeout( + protocol.sendPayment( + invoice.bolt11, + protocol.config, + { signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) } + ), WALLET_SEND_PAYMENT_TIMEOUT_MS) - logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) + logger.ok(`↗ payment sent: ${formatSats(invoice.satsRequested)}`) } catch (err) { // we don't log the error here since we want to handle receiver errors separately const message = err.message || err.toString?.() - throw new WalletSenderError(wallet.def.name, invoice, message) + throw new WalletSenderError(protocol.name, invoice, message) } }, []) } diff --git a/wallets/prompt.js b/wallets/client/hooks/prompt.js similarity index 69% rename from wallets/prompt.js rename to wallets/client/hooks/prompt.js index cf62cfde..5fe0031a 100644 --- a/wallets/prompt.js +++ b/wallets/client/hooks/prompt.js @@ -5,14 +5,12 @@ import { Form, ClientInput, SubmitButton, Checkbox } from '@/components/form' import { useMe } from '@/components/me' import { useShowModal } from '@/components/modal' import Link from 'next/link' -import { useWallet } from '@/wallets/index' -import { useWalletConfigurator } from '@/wallets/config' import styles from '@/styles/wallet.module.css' -import { externalLightningAddressValidator } from '@/lib/validate' -import { autowithdrawInitial } from '@/components/autowithdraw-shared' import { useMutation } from '@apollo/client' import { HIDE_WALLET_RECV_PROMPT_MUTATION } from '@/fragments/users' import { useToast } from '@/components/toast' +import { useLightningAddressUpsert } from '@/wallets/client/hooks/query' +import { protocolClientSchema } from '@/wallets/lib/util' export class WalletPromptClosed extends Error { constructor () { @@ -40,7 +38,6 @@ export function useWalletRecvPrompt () { return useCallback((e) => { return new Promise((resolve, reject) => { - // TODO: check if user told us to not show again if (!me || me.optional?.hasRecvWallet || me.privates?.hideWalletRecvPrompt) return resolve() showModal(onClose => { @@ -60,31 +57,26 @@ export function useWalletRecvPrompt () { }, [!!me, me?.optional?.hasRecvWallet, me?.privates?.hideWalletRecvPrompt, showModal, onAttach, onSkip]) } -const Header = () => ( -
- You need to attach a
- lightning wallet -
- to receive sats -
-) +function Header () { + return ( +
+ You need to attach a
+ lightning wallet +
+ to receive sats +
+ ) +} -const LnAddrForm = ({ onAttach }) => { - const { me } = useMe() - const wallet = useWallet('lightning-address') - const { save } = useWalletConfigurator(wallet) +function LnAddrForm ({ onAttach }) { + const upsert = useLightningAddressUpsert() + const schema = protocolClientSchema({ name: 'LN_ADDR', send: false }) + const initial = { address: '' } - const schema = object({ lnAddr: externalLightningAddressValidator.required('required') }) - - const onSubmit = useCallback(async ({ lnAddr }) => { - await save({ - ...autowithdrawInitial({ me }), - priority: 0, - enabled: true, - address: lnAddr - }, true) + const onSubmit = useCallback(async ({ address }) => { + await upsert({ address }) onAttach() - }, [save]) + }, [upsert, onAttach]) return ( <> @@ -92,10 +84,10 @@ const LnAddrForm = ({ onAttach }) => {
save} /> @@ -104,9 +96,11 @@ const LnAddrForm = ({ onAttach }) => { ) } -const WalletLink = () => visit wallets to set up a different wallet +function WalletLink () { + return visit wallets to set up a different wallet +} -const SkipForm = ({ onSkip }) => { +function SkipForm ({ onSkip }) { const { me } = useMe() const [hideWalletRecvPrompt] = useMutation(HIDE_WALLET_RECV_PROMPT_MUTATION, { update (cache) { @@ -143,9 +137,11 @@ const SkipForm = ({ onSkip }) => { ) } -const Footer = () => ( -
- Stacker News is non-custodial. If you don't attach a wallet, you will receive credits when zapped. - See the FAQ for the details. -
-) +function Footer () { + return ( +
+ Stacker News is non-custodial. If you don't attach a wallet, you will receive credits when zapped. + See the FAQ for the details. +
+ ) +} diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js new file mode 100644 index 00000000..9e1e97cf --- /dev/null +++ b/wallets/client/hooks/query.js @@ -0,0 +1,514 @@ +import { + WALLET, + UPSERT_WALLET_RECEIVE_BLINK, + UPSERT_WALLET_RECEIVE_CLN_REST, + UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS, + UPSERT_WALLET_RECEIVE_LNBITS, + UPSERT_WALLET_RECEIVE_LNDGRPC, + UPSERT_WALLET_RECEIVE_NWC, + UPSERT_WALLET_RECEIVE_PHOENIXD, + UPSERT_WALLET_SEND_BLINK, + UPSERT_WALLET_SEND_LNBITS, + UPSERT_WALLET_SEND_LNC, + UPSERT_WALLET_SEND_NWC, + UPSERT_WALLET_SEND_PHOENIXD, + UPSERT_WALLET_SEND_WEBLN, + WALLETS, + REMOVE_WALLET_PROTOCOL, + UPDATE_WALLET_ENCRYPTION, + RESET_WALLETS, + DISABLE_PASSPHRASE_EXPORT, + SET_WALLET_PRIORITIES, + UPDATE_KEY_HASH +} from '@/wallets/client/fragments' +import { useApolloClient, useMutation, useQuery } from '@apollo/client' +import { useDecryption, useEncryption, useSetKey, useWalletLogger, WalletStatus } from '@/wallets/client/hooks' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName +} from '@/wallets/lib/util' +import { protocolTestSendPayment } from '@/wallets/client/protocols' +import { timeoutSignal } from '@/lib/time' +import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' +import { useToast } from '@/components/toast' +import { useMe } from '@/components/me' +import { useWallets, useLoading as useWalletsLoading } from '@/wallets/client/context' + +export function useWalletsQuery () { + const { me } = useMe() + const query = useQuery(WALLETS, { skip: !me }) + const [wallets, setWallets] = useState(null) + + const { decryptWallet, ready } = useWalletDecryption() + + useEffect(() => { + if (!query.data?.wallets || !ready) return + Promise.all( + query.data?.wallets.map(w => decryptWallet(w)) + ) + .then(wallets => wallets.map(protocolCheck)) + .then(wallets => wallets.map(undoFieldAlias)) + .then(wallets => setWallets(wallets)) + .catch(err => { + console.error('failed to decrypt wallets:', err) + setWallets([]) + }) + }, [query.data, decryptWallet, ready]) + + useRefetchOnChange(query.refetch) + + return useMemo(() => ({ + ...query, + loading: !wallets, + data: wallets ? { wallets } : null + }), [query, wallets]) +} + +function protocolCheck (wallet) { + if (isTemplate(wallet)) return wallet + + const protocols = wallet.protocols.map(protocol => { + return { + ...protocol, + enabled: protocol.enabled && protocolAvailable(protocol) + } + }) + + const sendEnabled = protocols.some(p => p.send && p.enabled) + const receiveEnabled = protocols.some(p => !p.send && p.enabled) + + return { + ...wallet, + send: !sendEnabled ? WalletStatus.DISABLED : wallet.send, + receive: !receiveEnabled ? WalletStatus.DISABLED : wallet.receive, + protocols + } +} + +function undoFieldAlias ({ id, ...wallet }) { + // Just like for encrypted fields, we have to use a field alias for the name field of templates + // because of https://github.com/graphql/graphql-js/issues/53. + // We undo this here so this only affects the GraphQL layer but not the rest of the code. + if (isTemplate(wallet)) { + return { ...wallet, name: id } + } + + if (!wallet.template) return wallet + + const { id: templateId, ...template } = wallet.template + return { id, ...wallet, template: { name: templateId, ...template } } +} + +function useRefetchOnChange (refetch) { + const { me } = useMe() + + useEffect(() => { + if (!me?.id) return + + refetch() + }, [refetch, me?.id, me?.privates?.walletsUpdatedAt]) +} + +export function useWalletQuery ({ id, name }) { + const { me } = useMe() + const query = useQuery(WALLET, { variables: { id, name }, skip: !me }) + const [wallet, setWallet] = useState(null) + + const { decryptWallet, ready } = useWalletDecryption() + + useEffect(() => { + if (!query.data?.wallet || !ready) return + decryptWallet(query.data?.wallet) + .then(protocolCheck) + .then(undoFieldAlias) + .then(wallet => setWallet(wallet)) + .catch(err => { + console.error('failed to decrypt wallet:', err) + }) + }, [query.data, decryptWallet, ready]) + + return useMemo(() => ({ + ...query, + loading: !wallet, + data: wallet ? { wallet } : null + }), [query, wallet]) +} + +export function useWalletProtocolUpsert (wallet, protocol) { + const mutation = getWalletProtocolMutation(protocol) + const [mutate] = useMutation(mutation) + const { encryptConfig } = useEncryptConfig(protocol) + const testSendPayment = useTestSendPayment(protocol) + const logger = useWalletLogger(protocol) + + return useCallback(async (values) => { + logger.info('saving wallet ...') + + if (isTemplate(protocol)) { + values.enabled = true + } + + // skip network tests if we're disabling the wallet + const networkTests = values.enabled + if (networkTests) { + try { + const additionalValues = await testSendPayment(values) + values = { ...values, ...additionalValues } + } catch (err) { + logger.error(err.message) + throw err + } + } + + const encrypted = await encryptConfig(values) + + const variables = encrypted + if (!protocol.send) { + variables.networkTests = networkTests + } + if (isWallet(wallet)) { + variables.walletId = wallet.id + } else { + variables.templateName = wallet.name + } + + let updatedWallet + try { + const { data } = await mutate({ variables }) + logger.ok('wallet saved') + updatedWallet = Object.values(data)[0] + } catch (err) { + logger.error(err.message) + throw err + } + + return updatedWallet + }, [wallet, protocol, logger, testSendPayment, encryptConfig, mutate]) +} + +export function useLightningAddressUpsert () { + // TODO(wallet-v2): parse domain from address input to use correct wallet template + // useWalletProtocolUpsert needs to support passing in the wallet in the callback for that + const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' } + const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' } + return useWalletProtocolUpsert(wallet, protocol) +} + +export function useWalletProtocolRemove (protocol) { + const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL) + const toaster = useToast() + + return useCallback(async () => { + try { + await mutate({ variables: { id: protocol.id } }) + toaster.success('protocol detached') + } catch (err) { + toaster.danger('failed to detach protocol: ' + err.message) + } + }, [protocol?.id, mutate, toaster]) +} + +export function useWalletEncryptionUpdate () { + const wallets = useWallets() + const [mutate] = useMutation(UPDATE_WALLET_ENCRYPTION) + const setKey = useSetKey() + const { encryptConfig } = useEncryptConfig() + + return useCallback(async ({ key, hash }) => { + const encrypted = await Promise.all( + wallets.map(async d => ({ + ...d, + protocols: await Promise.all( + d.protocols.map(p => { + return encryptConfig(p.config, { key, hash, protocol: p }) + })) + })) + ) + + const data = encrypted.map(wallet => ({ + id: wallet.id, + protocols: wallet.protocols.map(protocol => { + const { id, __typename: relationName, ...config } = protocol + const { name, send } = reverseProtocolRelationName(relationName) + return { name, send, config } + }) + })) + + await mutate({ variables: { keyHash: hash, wallets: data } }) + + await setKey({ key, hash }) + }, [wallets, mutate, setKey, encryptConfig]) +} + +export function useWalletReset () { + const [mutate] = useMutation(RESET_WALLETS) + + return useCallback(async ({ newKeyHash }) => { + await mutate({ variables: { newKeyHash } }) + }, [mutate]) +} + +export function useDisablePassphraseExport () { + const [mutate] = useMutation(DISABLE_PASSPHRASE_EXPORT) + + return useCallback(async () => { + await mutate() + }, [mutate]) +} + +export function useSetWalletPriorities () { + const [mutate] = useMutation(SET_WALLET_PRIORITIES) + const toaster = useToast() + + return useCallback(async (wallets) => { + const priorities = wallets.map((wallet, index) => ({ + id: wallet.id, + priority: index + })) + + try { + await mutate({ variables: { priorities } }) + } catch (err) { + console.error('failed to update wallet priorities:', err) + toaster.danger('failed to update wallet priorities') + } + }, [mutate, toaster]) +} + +function getWalletProtocolMutation (protocol) { + switch (protocol.name) { + case 'LNBITS': + return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS + case 'PHOENIXD': + return protocol.send ? UPSERT_WALLET_SEND_PHOENIXD : UPSERT_WALLET_RECEIVE_PHOENIXD + case 'BLINK': + return protocol.send ? UPSERT_WALLET_SEND_BLINK : UPSERT_WALLET_RECEIVE_BLINK + case 'LN_ADDR': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS + case 'NWC': + return protocol.send ? UPSERT_WALLET_SEND_NWC : UPSERT_WALLET_RECEIVE_NWC + case 'CLN_REST': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_CLN_REST + case 'LND_GRPC': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_LNDGRPC + case 'LNC': + return protocol.send ? UPSERT_WALLET_SEND_LNC : null + case 'WEBLN': + return protocol.send ? UPSERT_WALLET_SEND_WEBLN : null + default: + return null + } +} + +function useTestSendPayment (protocol) { + return useCallback(async (values) => { + if (!protocol.send) return + + return await protocolTestSendPayment( + protocol, + values, + { signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) } + ) + }, [protocol]) +} + +function useWalletDecryption () { + const { decryptConfig, ready } = useDecryptConfig() + + const decryptWallet = useCallback(async wallet => { + if (!isWallet(wallet)) return wallet + + const protocols = await Promise.all( + wallet.protocols.map( + async protocol => ({ + ...protocol, + config: await decryptConfig(protocol.config) + }) + ) + ) + return { ...wallet, protocols } + }, [decryptConfig]) + + return useMemo(() => ({ decryptWallet, ready }), [decryptWallet, ready]) +} + +function useDecryptConfig () { + const { decrypt, ready } = useDecryption() + + const decryptConfig = useCallback(async (config) => { + return Object.fromEntries( + await Promise.all( + Object.entries(config) + .map( + async ([key, value]) => { + if (!isEncrypted(value)) return [key, value] + + // undo the field aliases we had to use because of https://github.com/graphql/graphql-js/issues/53 + // so we can pretend the GraphQL API returns the fields as they are named in the schema + let renamed = key.replace(/^encrypted/, '') + renamed = renamed.charAt(0).toLowerCase() + renamed.slice(1) + + return [ + renamed, + await decrypt(value) + ] + } + ) + ) + ) + }, [decrypt]) + + return useMemo(() => ({ decryptConfig, ready }), [decryptConfig, ready]) +} + +function isEncrypted (value) { + return value.__typename === 'VaultEntry' +} + +function useEncryptConfig (defaultProtocol, options = {}) { + const { encrypt, ready } = useEncryption(options) + + const encryptConfig = useCallback(async (config, { key: cryptoKey, hash, protocol } = {}) => { + return Object.fromEntries( + await Promise.all( + Object.entries(config) + .map( + async ([fieldKey, value]) => { + if (!isEncryptedField(protocol ?? defaultProtocol, fieldKey)) return [fieldKey, value] + return [ + fieldKey, + await encrypt(value, { key: cryptoKey, hash }) + ] + } + ) + ) + ) + }, [defaultProtocol, encrypt]) + + return useMemo(() => ({ encryptConfig, ready }), [encryptConfig, ready]) +} + +// TODO(wallet-v2): remove migration code +// ============================================================= +// ****** Below is the migration code for WALLET v1 -> v2 ****** +// remove when we can assume migration is complete (if ever) +// ============================================================= + +export function useWalletMigrationMutation () { + const wallets = useWallets() + const loading = useWalletsLoading() + const client = useApolloClient() + const { encryptConfig, ready } = useEncryptConfig() + + // XXX We use a ref for the wallets to avoid duplicate wallets + // Without a ref, the migrate callback would depend on the wallets and thus update every time the migration creates a wallet. + // This update would then cause the useEffect in wallets/client/context/hooks that triggers the migration to run again before the first migration is complete. + const walletsRef = useRef(wallets) + useEffect(() => { + if (!loading) walletsRef.current = wallets + }, [loading]) + + const migrate = useCallback(async ({ name, enabled, ...configV1 }) => { + const protocol = { name, send: true } + + const configV2 = migrateConfig(protocol, configV1) + + const isSameProtocol = (p) => { + const sameName = p.name === protocol.name + const sameSend = p.send === protocol.send + const sameConfig = Object.keys(p.config) + .filter(k => !['__typename', 'id'].includes(k)) + .every(k => p.config[k] === configV2[k]) + return sameName && sameSend && sameConfig + } + + const exists = walletsRef.current.some(w => w.name === name && w.protocols.some(isSameProtocol)) + if (exists) return + + const schema = protocolClientSchema(protocol) + await schema.validate(configV2) + + const encrypted = await encryptConfig(configV2, { protocol }) + + // decide if we create a new wallet (templateName) or use an existing one (walletId) + const templateName = getWalletTemplateName(protocol) + let walletId + const wallet = walletsRef.current.find(w => + w.name === name && !w.protocols.some(p => p.name === protocol.name && p.send) + ) + if (wallet) { + walletId = Number(wallet.id) + } + + await client.mutate({ + mutation: getWalletProtocolMutation(protocol), + variables: { + ...(walletId ? { walletId } : { templateName }), + enabled, + ...encrypted + } + }) + }, [client, encryptConfig]) + + return useMemo(() => ({ migrate, ready: ready && !loading }), [migrate, ready, loading]) +} + +export function useUpdateKeyHash () { + const [mutate] = useMutation(UPDATE_KEY_HASH) + + return useCallback(async (keyHash) => { + await mutate({ variables: { keyHash } }) + }, [mutate]) +} + +function migrateConfig (protocol, config) { + switch (protocol.name) { + case 'LNBITS': + return { + url: config.url, + apiKey: config.adminKey + } + case 'PHOENIXD': + return { + url: config.url, + apiKey: config.primaryPassword + } + case 'BLINK': + return { + url: config.url, + apiKey: config.apiKey, + currency: config.currency + } + case 'LNC': + return { + pairingPhrase: config.pairingPhrase, + localKey: config.localKey, + remoteKey: config.remoteKey, + serverHost: config.serverHost + } + case 'WEBLN': + return {} + case 'NWC': + return { + url: config.nwcUrl + } + default: + return config + } +} + +function getWalletTemplateName (protocol) { + switch (protocol.name) { + case 'LNBITS': + case 'PHOENIXD': + case 'BLINK': + case 'NWC': + return protocol.name + case 'LNC': + return 'LND' + case 'WEBLN': + return 'ALBY' + default: + return null + } +} diff --git a/wallets/client/hooks/wallet.js b/wallets/client/hooks/wallet.js new file mode 100644 index 00000000..95c14c41 --- /dev/null +++ b/wallets/client/hooks/wallet.js @@ -0,0 +1,49 @@ +import { useWallets } from '@/wallets/client/context' +import protocols from '@/wallets/client/protocols' +import { isWallet } from '@/wallets/lib/util' +import { useMemo } from 'react' + +export function useSendProtocols () { + const wallets = useWallets() + return useMemo( + () => wallets + .filter(w => w.send) + .reduce((acc, wallet) => { + return [ + ...acc, + ...wallet.protocols + .filter(p => p.send && p.enabled) + .map(walletProtocol => { + const { sendPayment } = protocols.find(p => p.name === walletProtocol.name) + return { + ...walletProtocol, + sendPayment + } + }) + ] + }, []) + , [wallets]) +} + +export function useHasSendWallet () { + const protocols = useSendProtocols() + return useMemo(() => protocols.length > 0, [protocols]) +} + +export function useWalletSupport (wallet) { + const template = isWallet(wallet) ? wallet.template : wallet + return useMemo(() => ({ receive: template.receive === WalletStatus.OK, send: template.send === WalletStatus.OK }), [template]) +} + +export const WalletStatus = { + OK: 'OK', + ERROR: 'ERROR', + WARNING: 'WARNING', + DISABLED: 'DISABLED' +} + +export function useWalletStatus (wallet) { + if (!isWallet(wallet)) return WalletStatus.DISABLED + + return useMemo(() => ({ send: wallet.send, receive: wallet.receive }), [wallet]) +} diff --git a/wallets/blink/client.js b/wallets/client/protocols/blink.js similarity index 94% rename from wallets/blink/client.js rename to wallets/client/protocols/blink.js index c5a487b8..8bb00bfb 100644 --- a/wallets/blink/client.js +++ b/wallets/client/protocols/blink.js @@ -1,9 +1,13 @@ -import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' -export * from '@/wallets/blink' +import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/lib/protocols/blink' -export async function testSendPayment ({ apiKey, currency }, { logger, signal }) { - logger.info('trying to fetch ' + currency + ' wallet') +export const name = 'BLINK' +export async function sendPayment (bolt11, { apiKey, currency }, { signal }) { + const wallet = await getWallet({ apiKey, currency }, { signal }) + return await payInvoice(bolt11, { apiKey, wallet }, { signal }) +} + +export async function testSendPayment ({ apiKey, currency }, { signal }) { const scopes = await getScopes({ apiKey }, { signal }) if (!scopes.includes(SCOPE_READ)) { throw new Error('missing READ scope') @@ -14,13 +18,6 @@ export async function testSendPayment ({ apiKey, currency }, { logger, signal }) currency = currency ? currency.toUpperCase() : 'BTC' await getWallet({ apiKey, currency }, { signal }) - - logger.ok(currency + ' wallet found') -} - -export async function sendPayment (bolt11, { apiKey, currency }, { signal }) { - const wallet = await getWallet({ apiKey, currency }, { signal }) - return await payInvoice(bolt11, { apiKey, wallet }, { signal }) } async function payInvoice (bolt11, { apiKey, wallet }, { signal }) { diff --git a/wallets/client/protocols/index.js b/wallets/client/protocols/index.js new file mode 100644 index 00000000..a7cb149b --- /dev/null +++ b/wallets/client/protocols/index.js @@ -0,0 +1,56 @@ +import * as nwc from './nwc' +import * as lnbits from './lnbits' +import * as phoenixd from './phoenixd' +import * as blink from './blink' +import * as webln from './webln' +import * as lnc from './lnc' + +export * from './util' + +/** + * @typedef {@import('@/wallets/lib/protocols').ProtocolName} ProtocolName + */ + +/** + * @typedef {Object} ClientWalletProtocol + * @property {ProtocolName} name - must match a protocol name in the database + * @property {ProtocolCreateInvoice} createInvoice - create a new invoice + * @property {ProtocolTestCreateInvoice} testCreateInvoice - create a test invoice + */ + +/** + * @callback ProtocolSendPayment + * @param {SendPaymentArgs} args - arguments for the payment + * @param {Object} config - current protocol configuration + * @param {SendPaymentOptions} opts - additional options for the payment + * @returns {Promise} - preimage + */ + +/** + * @typedef {Object} SendPaymentArgs + * @property {number} bolt11 - the bolt11 invoice the wallet should pay + */ + +/** + * @typedef {Object} SendPaymentOptions + * @property {AbortSignal} signal - signal to abort the request + */ + +/** + * @callback ProtocolTestSendPayment + * @param {Object} config - current protocol configuration + * @param {SendPaymentOptions} opts - additional options for the payment + * @returns {Promise} + */ + +/** @typedef {string} Preimage */ + +/** @type {ClientWalletProtocol[]} */ +export default [ + nwc, + lnbits, + phoenixd, + blink, + webln, + lnc +] diff --git a/wallets/lnbits/client.js b/wallets/client/protocols/lnbits.js similarity index 69% rename from wallets/lnbits/client.js rename to wallets/client/protocols/lnbits.js index 915840f5..17f02322 100644 --- a/wallets/lnbits/client.js +++ b/wallets/client/protocols/lnbits.js @@ -1,23 +1,14 @@ import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson } from '@/lib/url' -export * from '@/wallets/lnbits' +export const name = 'LNBITS' -export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) { - logger.info('trying to fetch wallet') - - url = url.replace(/\/+$/, '') - await getWallet({ url, adminKey, invoiceKey }, { signal }) - - logger.ok('wallet found') -} - -export async function sendPayment (bolt11, { url, adminKey }, { signal }) { +export async function sendPayment (bolt11, { url, apiKey }, { signal }) { url = url.replace(/\/+$/, '') - const response = await postPayment(bolt11, { url, adminKey }, { signal }) + const response = await postPayment(bolt11, { url, apiKey }, { signal }) - const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal }) + const checkResponse = await getPayment(response.payment_hash, { url, apiKey }, { signal }) if (!checkResponse.preimage) { throw new Error('No preimage') } @@ -25,13 +16,18 @@ export async function sendPayment (bolt11, { url, adminKey }, { signal }) { return checkResponse.preimage } -async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { +export async function testSendPayment ({ url, apiKey }, { signal }) { + url = url.replace(/\/+$/, '') + await getWallet({ url, apiKey }, { signal }) +} + +async function getWallet ({ url, apiKey }, { signal }) { const path = '/api/v1/wallet' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey || invoiceKey) + headers.append('X-Api-Key', apiKey) const method = 'GET' const res = await fetchWithTimeout(url + path, { method, headers, signal }) @@ -46,13 +42,13 @@ async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { return wallet } -async function postPayment (bolt11, { url, adminKey }, { signal }) { +async function postPayment (bolt11, { url, apiKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) + headers.append('X-Api-Key', apiKey) const body = JSON.stringify({ bolt11, out: true }) @@ -69,13 +65,13 @@ async function postPayment (bolt11, { url, adminKey }, { signal }) { return payment } -async function getPayment (paymentHash, { url, adminKey }, { signal }) { +async function getPayment (paymentHash, { url, apiKey }, { signal }) { const path = `/api/v1/payments/${paymentHash}` const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) + headers.append('X-Api-Key', apiKey) const method = 'GET' const res = await fetchWithTimeout(url + path, { method, headers, signal }) diff --git a/wallets/lnc/client.js b/wallets/client/protocols/lnc.js similarity index 99% rename from wallets/lnc/client.js rename to wallets/client/protocols/lnc.js index 6009f0f2..cf4efa93 100644 --- a/wallets/lnc/client.js +++ b/wallets/client/protocols/lnc.js @@ -1,17 +1,10 @@ import { Mutex } from 'async-mutex' -export * from '@/wallets/lnc' + +export const name = 'LNC' const mutex = new Mutex() const serverHost = 'mailbox.terminal.lightning.today:443' -export async function testSendPayment (credentials, { logger }) { - const lnc = await getLNC(credentials, { logger }) - logger?.info('validating permissions ...') - await validateNarrowPerms(lnc) - logger?.info('permissions ok') - return lnc.credentials.credentials -} - export async function sendPayment (bolt11, credentials, { logger }) { return await mutex.runExclusive(async () => { const lnc = await getLNC(credentials, { logger }) @@ -22,6 +15,14 @@ export async function sendPayment (bolt11, credentials, { logger }) { }) } +export async function testSendPayment (credentials, { logger }) { + const lnc = await getLNC(credentials, { logger }) + logger?.info('validating permissions ...') + await validateNarrowPerms(lnc) + logger?.info('permissions ok') + return lnc.credentials.credentials +} + async function disconnectLNC (lnc, { logger } = {}) { try { if (!lnc?.isConnected) return diff --git a/wallets/client/protocols/nwc.js b/wallets/client/protocols/nwc.js new file mode 100644 index 00000000..2197a60e --- /dev/null +++ b/wallets/client/protocols/nwc.js @@ -0,0 +1,15 @@ +import { supportedMethods, nwcTryRun } from '@/wallets/lib/protocols/nwc' + +export const name = 'NWC' + +export async function sendPayment (bolt11, { url }, { signal }) { + const result = await nwcTryRun(nwc => nwc.lnPay({ pr: bolt11 }), { url }, { signal }) + return result.preimage +} + +export async function testSendPayment ({ url }, { signal }) { + const supported = await supportedMethods(url, { signal }) + if (!supported.includes('pay_invoice')) { + throw new Error('pay_invoice not supported') + } +} diff --git a/wallets/phoenixd/client.js b/wallets/client/protocols/phoenixd.js similarity index 74% rename from wallets/phoenixd/client.js rename to wallets/client/protocols/phoenixd.js index db9e438b..2484edb6 100644 --- a/wallets/phoenixd/client.js +++ b/wallets/client/protocols/phoenixd.js @@ -1,22 +1,14 @@ import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/phoenixd' +export const name = 'PHOENIXD' -export async function testSendPayment (config, { logger, signal }) { - // TODO: - // Not sure which endpoint to call to test primary password - // see https://phoenix.acinq.co/server/api - // Maybe just wait until test payments with HODL invoices? - -} - -export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) { +export async function sendPayment (bolt11, { url, apiKey }, { signal }) { // https://phoenix.acinq.co/server/api#pay-bolt11-invoice const path = '/payinvoice' const headers = new Headers() - headers.set('Authorization', 'Basic ' + Buffer.from(':' + primaryPassword).toString('base64')) + headers.set('Authorization', 'Basic ' + Buffer.from(':' + apiKey).toString('base64')) headers.set('Content-type', 'application/x-www-form-urlencoded') const body = new URLSearchParams() @@ -41,3 +33,11 @@ export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) return preimage } + +export async function testSendPayment (config, { signal }) { + // TODO: + // Not sure which endpoint to call to test primary password + // see https://phoenix.acinq.co/server/api + // Maybe just wait until test payments with HODL invoices? + // https://github.com/stackernews/stacker.news/issues/1287 +} diff --git a/wallets/client/protocols/util.js b/wallets/client/protocols/util.js new file mode 100644 index 00000000..4f5d04c4 --- /dev/null +++ b/wallets/client/protocols/util.js @@ -0,0 +1,13 @@ +import protocols from '@/wallets/client/protocols' + +function protocol (name) { + return protocols.find(protocol => protocol.name === name) +} + +export function protocolSendPayment ({ name }, args, config, opts) { + return protocol(name).sendPayment(args, config, opts) +} + +export function protocolTestSendPayment ({ name }, config, opts) { + return protocol(name).testSendPayment(config, opts) +} diff --git a/wallets/client/protocols/webln.js b/wallets/client/protocols/webln.js new file mode 100644 index 00000000..b9c37c42 --- /dev/null +++ b/wallets/client/protocols/webln.js @@ -0,0 +1,33 @@ +import { WalletError } from '@/wallets/client/errors' + +export const name = 'WEBLN' + +export async function sendPayment (bolt11) { + if (typeof window.webln === 'undefined') { + throw new WalletError('lightning browser extension not found') + } + + // this will prompt the user to unlock the wallet if it's locked + try { + await window.webln.enable() + } catch (err) { + throw new WalletError(err.message) + } + + // this will prompt for payment if no budget is set + const response = await window.webln.sendPayment(bolt11) + if (!response) { + // sendPayment returns nothing if WebLN was enabled + // but browser extension that provides WebLN was then disabled + // without reloading the page + throw new WalletError('sendPayment returned no response') + } + + return response.preimage +} + +export async function testSendPayment () { + if (typeof window.webln === 'undefined') { + throw new WalletError('lightning browser extension not found') + } +} diff --git a/wallets/cln/client.js b/wallets/cln/client.js deleted file mode 100644 index 97b542b3..00000000 --- a/wallets/cln/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/cln' diff --git a/wallets/cln/index.js b/wallets/cln/index.js deleted file mode 100644 index f2c7ab13..00000000 --- a/wallets/cln/index.js +++ /dev/null @@ -1,72 +0,0 @@ -import { decodeRune } from '@/lib/cln' -import { B64_URL_REGEX } from '@/lib/format' -import { string } from '@/lib/yup' - -export const name = 'cln' -export const walletType = 'CLN' -export const walletField = 'walletCLN' - -export const fields = [ - { - name: 'socket', - label: 'rest host and port', - type: 'text', - placeholder: '55.5.555.55:3010', - hint: 'tor or clearnet', - clear: true, - serverOnly: true, - validate: string().socket() - }, - { - name: 'rune', - label: 'invoice only rune', - help: { - text: 'We only accept runes that *only* allow `method=invoice`.\n\n' + - 'Run this if you are on v23.08 to generate one:\n\n' + - '```lightning-cli createrune restrictions=\'["method=invoice"]\'```\n\n' + - 'Or this if you are on v24.11 or later:\n\n' + - '```lightning-cli createrune restrictions=\'[["method=invoice"]]\'```\n\n' + - '[see `createrune` documentation](https://docs.corelightning.org/reference/lightning-createrune#restriction-format)' - }, - type: 'text', - placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', - hint: 'must be restricted to method=invoice', - clear: true, - serverOnly: true, - validate: string().matches(B64_URL_REGEX, { message: 'invalid rune' }) - .test({ - name: 'rune', - test: (v, context) => { - const decoded = decodeRune(v) - if (!decoded) return context.createError({ message: 'invalid rune' }) - if (decoded.restrictions.length === 0) { - return context.createError({ message: 'rune must be restricted to method=invoice' }) - } - if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - return true - } - }) - }, - { - name: 'cert', - label: 'cert', - type: 'text', - placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', - optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64() - } -] - -export const card = { - title: 'CLN', - subtitle: 'receive zaps to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', - image: { src: '/wallets/cln.svg' } -} diff --git a/wallets/common.js b/wallets/common.js deleted file mode 100644 index 4064a807..00000000 --- a/wallets/common.js +++ /dev/null @@ -1,174 +0,0 @@ -import walletDefs from '@/wallets/client' - -export function getWalletByName (name) { - return walletDefs.find(def => def.name === name) -} - -export function getWalletByType (type) { - return walletDefs.find(def => def.walletType === type) -} - -export function getStorageKey (name, userId) { - let storageKey = `wallet:${name}` - - // WebLN has no credentials we need to scope to users - // so we can use the same storage key for all users - if (userId && name !== 'webln') { - storageKey = `${storageKey}:${userId}` - } - - return storageKey -} - -export function walletTag (walletDef) { - return walletDef.shortName || walletDef.name -} - -export function walletPrioritySort (w1, w2) { - // enabled/configured wallets always come before disabled/unconfigured wallets - if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) { - return -1 - } else if ((w2.config?.enabled && !w1.config?.enabled) || (isConfigured(w2) && !isConfigured(w1))) { - return 1 - } - - const delta = w1.config?.priority - w2.config?.priority - // delta is NaN if either priority is undefined - if (!Number.isNaN(delta) && delta !== 0) return delta - - // if one wallet has a priority but the other one doesn't, the one with the priority comes first - if (w1.config?.priority !== undefined && w2.config?.priority === undefined) return -1 - if (w1.config?.priority === undefined && w2.config?.priority !== undefined) return 1 - - // both wallets have no priority set, falling back to other methods - - // if both wallets have an id, use that as tie breaker - // since that's the order in which autowithdrawals are attempted - if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) - - // else we will use the card title as tie breaker - return w1.def.card.title < w2.def.card.title ? -1 : 1 -} - -export function isServerField (f) { - return f.serverOnly || !f.clientOnly -} - -export function isClientField (f) { - return f.clientOnly || !f.serverOnly -} - -function checkFields ({ fields, config }) { - // a wallet is configured if all of its required fields are set - let val = fields.every(f => { - if ((f.optional || f.generated) && !f.requiredWithout) return true - return !!config?.[f.name] - }) - - // however, a wallet is not configured if all fields are optional and none are set - // since that usually means that one of them is required - if (val && fields.length > 0) { - val = !(fields.every(f => f.optional || f.generated) && fields.every(f => !config?.[f.name])) - } - - return val -} - -export function isConfigured ({ def, config }) { - return isSendConfigured({ def, config }) || isReceiveConfigured({ def, config }) -} - -function isSendConfigured ({ def, config }) { - const fields = def.fields.filter(isClientField) - return (fields.length > 0 || def.isAvailable?.()) && checkFields({ fields, config }) -} - -function isReceiveConfigured ({ def, config }) { - const fields = def.fields.filter(isServerField) - return fields.length > 0 && checkFields({ fields, config }) -} - -export function supportsSend ({ def, config }) { - return !!def.sendPayment -} - -export function supportsReceive ({ def, config }) { - return def.fields.some(f => f.serverOnly) -} - -export function canSend ({ def, config }) { - return ( - supportsSend({ def, config }) && - isSendConfigured({ def, config }) && - (def.requiresConfig || config?.enabled) - ) -} - -export function canReceive ({ def, config }) { - return supportsReceive({ def, config }) && isReceiveConfigured({ def, config }) -} - -export function siftConfig (fields, config) { - const sifted = { - clientOnly: {}, - serverOnly: {}, - shared: {}, - serverWithShared: {}, - clientWithShared: {}, - settings: null - } - - for (const [key, value] of Object.entries(config)) { - if (['id'].includes(key)) { - sifted.serverOnly[key] = value - continue - } - - if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) { - sifted.serverOnly[key] = Number(value) - sifted.settings = { ...sifted.settings, [key]: Number(value) } - continue - } - - const field = fields.find(({ name }) => name === key) - - if (field) { - if (field.serverOnly) { - sifted.serverOnly[key] = value - } else if (field.clientOnly) { - sifted.clientOnly[key] = value - } else { - sifted.shared[key] = value - } - } else if (['enabled', 'priority'].includes(key)) { - sifted.shared[key] = value - } - } - - sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly } - sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly } - - return sifted -} - -export async function upsertWalletVariables ({ def, config }, encrypt, append = {}) { - const { serverWithShared, settings, clientOnly } = siftConfig(def.fields, config) - // if we are disconnected from the vault, we leave vaultEntries undefined so we don't - // delete entries from connected devices - let vaultEntries - if (clientOnly && encrypt) { - vaultEntries = [] - for (const [key, value] of Object.entries(clientOnly)) { - if (value) { - vaultEntries.push({ key, ...await encrypt(value) }) - } - } - } - - return { ...serverWithShared, settings, vaultEntries, ...append } -} - -export async function saveWalletLocally (name, config, userId) { - const storageKey = getStorageKey(name, userId) - window.localStorage.setItem(storageKey, JSON.stringify(config)) -} diff --git a/wallets/config.js b/wallets/config.js deleted file mode 100644 index 25f1f2ba..00000000 --- a/wallets/config.js +++ /dev/null @@ -1,154 +0,0 @@ -import { useMe } from '@/components/me' -import useVault from '@/components/vault/use-vault' -import { useCallback } from 'react' -import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common' -import { gql, useMutation } from '@apollo/client' -import { generateMutation } from './graphql' -import { REMOVE_WALLET } from '@/fragments/wallet' -import { useWalletLogger } from '@/wallets/logger' -import { useWallets } from '.' -import validateWallet from './validate' -import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' -import { timeoutSignal, withTimeout } from '@/lib/time' - -export function useWalletConfigurator (wallet) { - const { me } = useMe() - const { reloadLocalWallets } = useWallets() - const { encrypt, isActive } = useVault() - const logger = useWalletLogger(wallet) - const [upsertWallet] = useMutation(generateMutation(wallet?.def)) - const [removeWallet] = useMutation(REMOVE_WALLET) - const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) - - const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => { - const variables = await upsertWalletVariables( - { def: wallet.def, config: { ...serverConfig, ...clientConfig } }, - isActive && encrypt, - { validateLightning }) - await upsertWallet({ variables }) - }, [encrypt, isActive, wallet.def]) - - const _saveToLocal = useCallback(async (newConfig) => { - saveWalletLocally(wallet.def.name, newConfig, me?.id) - reloadLocalWallets() - }, [me?.id, wallet.def.name, reloadLocalWallets]) - - const _validate = useCallback(async (config, validateLightning = true) => { - const { serverWithShared, clientWithShared } = siftConfig(wallet.def.fields, config) - - let clientConfig = clientWithShared - let serverConfig = serverWithShared - - if (canSend({ def: wallet.def, config: clientConfig })) { - try { - let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true }) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - if (wallet.def.testSendPayment && validateLightning) { - transformedConfig = await withTimeout( - wallet.def.testSendPayment(clientConfig, { - logger, - signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) - }), - WALLET_SEND_PAYMENT_TIMEOUT_MS - ) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - // validate again to ensure generated fields are valid - await validateWallet(wallet.def, clientConfig) - } - } catch (err) { - logger.error(err.message) - throw err - } - } else if (canReceive({ def: wallet.def, config: serverConfig })) { - const transformedConfig = await validateWallet(wallet.def, serverConfig) - if (transformedConfig) { - serverConfig = Object.assign(serverConfig, transformedConfig) - } - } else if (wallet.def.requiresConfig) { - throw new Error('configuration must be able to send or receive') - } - - return { clientConfig, serverConfig } - }, [wallet, logger]) - - const _detachFromServer = useCallback(async () => { - await removeWallet({ variables: { id: wallet.config.id } }) - }, [wallet.config?.id]) - - const _detachFromLocal = useCallback(async () => { - window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) - reloadLocalWallets() - }, [me?.id, wallet.def.name, reloadLocalWallets]) - - const save = useCallback(async (newConfig, validateLightning = true) => { - const { clientWithShared: oldClientConfig } = siftConfig(wallet.def.fields, wallet.config) - const { clientConfig: newClientConfig, serverConfig: newServerConfig } = await _validate(newConfig, validateLightning) - - const oldCanSend = canSend({ def: wallet.def, config: oldClientConfig }) - const newCanSend = canSend({ def: wallet.def, config: newClientConfig }) - - // if vault is active, encrypt and send to server regardless of wallet type - if (isActive) { - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - await _detachFromLocal() - } else { - if (newCanSend) { - await _saveToLocal(newClientConfig) - } else { - // if it previously had a client config, remove it - await _detachFromLocal() - } - if (canReceive({ def: wallet.def, config: newServerConfig })) { - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - } else if (wallet.config.id) { - // we previously had a server config - if (wallet.vaultEntries.length > 0) { - // we previously had a server config with vault entries, save it - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - } else { - // we previously had a server config without vault entries, remove it - await _detachFromServer() - } - } - } - - if (newCanSend) { - disableFreebies().catch(console.error) - if (oldCanSend) { - logger.ok('details for sending updated') - } else { - logger.ok('details for sending saved') - } - if (newConfig.enabled) { - logger.ok('sending enabled') - } else { - logger.info('sending disabled') - } - } else if (oldCanSend) { - logger.info('details for sending deleted') - } - }, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate, - _detachFromLocal, _detachFromServer, disableFreebies]) - - const detach = useCallback(async () => { - if (isActive) { - // if vault is active, detach all wallets from server - await _detachFromServer() - } else { - if (wallet.config.id) { - await _detachFromServer() - } - - // if vault is not active and has a client config, delete from local storage - await _detachFromLocal() - } - - logger.info('details for sending deleted') - }, [logger, isActive, _detachFromServer, _detachFromLocal]) - - return { save, detach } -} diff --git a/wallets/graphql.js b/wallets/graphql.js deleted file mode 100644 index b39b6ebd..00000000 --- a/wallets/graphql.js +++ /dev/null @@ -1,51 +0,0 @@ -import gql from 'graphql-tag' -import { isServerField } from './common' -import { WALLET_FIELDS } from '@/fragments/wallet' - -export function fieldToGqlArg (field) { - let arg = `${field.name}: String` - if (!field.optional) { - arg += '!' - } - return arg -} - -// same as fieldToGqlArg, but makes the field always optional -export function fieldToGqlArgOptional (field) { - return `${field.name}: String` -} - -export function generateResolverName (walletField) { - const capitalized = walletField[0].toUpperCase() + walletField.slice(1) - return `upsert${capitalized}` -} - -export function generateTypeDefName (walletType) { - const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('') - return `Wallet${PascalCase}` -} - -export function generateMutation (wallet) { - const resolverName = generateResolverName(wallet.walletField) - - let headerArgs = '$id: ID, ' - headerArgs += wallet.fields - .filter(isServerField) - .map(f => `$${f.name}: String`) - .join(', ') - headerArgs += ', $enabled: Boolean, $priority: Int, $vaultEntries: [VaultEntryInput!], $settings: AutowithdrawSettings, $validateLightning: Boolean' - - let inputArgs = 'id: $id, ' - inputArgs += wallet.fields - .filter(isServerField) - .map(f => `${f.name}: $${f.name}`).join(', ') - inputArgs += ', enabled: $enabled, priority: $priority, vaultEntries: $vaultEntries, settings: $settings, validateLightning: $validateLightning' - - return gql` - ${WALLET_FIELDS} - mutation ${resolverName}(${headerArgs}) { - ${resolverName}(${inputArgs}) { - ...WalletFields - } - }` -} diff --git a/wallets/image.js b/wallets/image.js deleted file mode 100644 index f0d7a27c..00000000 --- a/wallets/image.js +++ /dev/null @@ -1,14 +0,0 @@ -import useDarkMode from '@/components/dark-mode' - -export function useWalletImage (wallet) { - const [darkMode] = useDarkMode() - - const { title, image } = wallet.def.card - - if (!image) return null - - // wallet.png <-> wallet-dark.png - const src = darkMode ? image?.src.replace(/\.([a-z]{3})$/, '-dark.$1') : image?.src - - return { ...image, alt: title, src } -} diff --git a/wallets/index.js b/wallets/index.js deleted file mode 100644 index 167a3428..00000000 --- a/wallets/index.js +++ /dev/null @@ -1,317 +0,0 @@ -import { useMe } from '@/components/me' -import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet' -import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' -import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client' -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common' -import useVault from '@/components/vault/use-vault' -import walletDefs from '@/wallets/client' -import { generateMutation } from './graphql' -import { useWalletPayment } from './payment' -import useInvoice from '@/components/use-invoice' -import { WalletConfigurationError } from './errors' - -const WalletsContext = createContext({ - wallets: [] -}) - -function useLocalWallets () { - const { me } = useMe() - const [wallets, setWallets] = useState([]) - - const loadWallets = useCallback(() => { - // form wallets from local storage into a list of { config, def } - const wallets = walletDefs.map(w => { - try { - const storageKey = getStorageKey(w.name, me?.id) - const config = window.localStorage.getItem(storageKey) - return { def: w, config: JSON.parse(config) } - } catch (e) { - return null - } - }).filter(Boolean) - setWallets(wallets) - }, [me?.id, setWallets]) - - const removeWallets = useCallback(() => { - for (const wallet of wallets) { - const storageKey = getStorageKey(wallet.def.name, me?.id) - window.localStorage.removeItem(storageKey) - } - setWallets([]) - }, [wallets, setWallets, me?.id]) - - useEffect(() => { - // listen for changes to any wallet config in local storage - // from any window with the same origin - const handleStorage = (event) => { - if (event.key?.startsWith(getStorageKey(''))) { - loadWallets() - } - } - window.addEventListener('storage', handleStorage) - - loadWallets() - return () => window.removeEventListener('storage', handleStorage) - }, [loadWallets]) - - return { wallets, reloadLocalWallets: loadWallets, removeLocalWallets: removeWallets } -} - -const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} })) - -export function WalletsProvider ({ children }) { - const { isActive, decrypt } = useVault() - const { me } = useMe() - const { wallets: localWallets, reloadLocalWallets, removeLocalWallets } = useLocalWallets() - const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY) - const [serverWallets, setServerWallets] = useState([]) - const client = useApolloClient() - const [loading, setLoading] = useState(true) - - const { data, refetch } = useQuery(WALLETS, - SSR ? {} : { nextFetchPolicy: 'cache-and-network' }) - - // refetch wallets when the vault key hash changes or wallets are updated - useEffect(() => { - if (me?.privates?.walletsUpdatedAt) { - refetch() - } - }, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch]) - - useEffect(() => { - const loadWallets = async () => { - if (!data?.wallets) return - // form wallets into a list of { config, def } - const wallets = [] - for (const w of data.wallets) { - const def = getWalletByType(w.type) - const { vaultEntries, ...config } = w - if (isActive) { - for (const { key, iv, value } of vaultEntries) { - try { - config[key] = await decrypt({ iv, value }) - } catch (e) { - console.error('error decrypting vault entry', e) - } - } - } - - // the specific wallet config on the server is stored in wallet.wallet - // on the client, it's stored unnested - wallets.push({ config: { ...config, ...w.wallet }, def, vaultEntries }) - } - - setServerWallets(wallets) - setLoading(false) - } - loadWallets() - }, [data?.wallets, decrypt, isActive]) - - // merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig } - const wallets = useMemo(() => { - const merged = {} - for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) { - merged[wallet.def.name] = { - def: { - ...wallet.def, - requiresConfig: wallet.def.fields.length > 0 - }, - config: { - ...merged[wallet.def.name]?.config, - ...Object.fromEntries( - Object.entries(wallet.config ?? {}).map(([key, value]) => [ - key, - value ?? merged[wallet.def.name]?.config?.[key] - ]) - ) - }, - vaultEntries: wallet.vaultEntries - } - } - - // sort by priority - return Object.values(merged).sort(walletPrioritySort) - }, [serverWallets, localWallets]) - - const settings = useMemo(() => { - return { - autoWithdrawMaxFeePercent: me?.privates?.autoWithdrawMaxFeePercent, - autoWithdrawThreshold: me?.privates?.autoWithdrawThreshold, - autoWithdrawMaxFeeTotal: me?.privates?.autoWithdrawMaxFeeTotal - } - }, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal]) - - // whenever the vault key is set, and we have local wallets, - // we'll send any merged local wallets to the server, and delete them from local storage - const syncLocalWallets = useCallback(async encrypt => { - const walletsToSync = wallets.filter(w => - // only sync wallets that have a local config - localWallets.some(localWallet => localWallet.def.name === w.def.name && !!localWallet.config) - ) - if (encrypt && walletsToSync.length > 0) { - for (const wallet of walletsToSync) { - const mutation = generateMutation(wallet.def) - const append = {} - // if the wallet has server-only fields set, add the settings to the mutation variables - if (wallet.def.fields.some(f => f.serverOnly && wallet.config[f.name])) { - append.settings = settings - } - const variables = await upsertWalletVariables(wallet, encrypt, append) - await client.mutate({ mutation, variables }) - } - removeLocalWallets() - } - }, [wallets, localWallets, removeLocalWallets, settings]) - - const unsyncLocalWallets = useCallback(() => { - for (const wallet of wallets) { - const { clientWithShared } = siftConfig(wallet.def.fields, wallet.config) - if (canSend({ def: wallet.def, config: clientWithShared })) { - saveWalletLocally(wallet.def.name, clientWithShared, me?.id) - } - } - reloadLocalWallets() - }, [wallets, me?.id, reloadLocalWallets]) - - const setPriorities = useCallback(async (priorities) => { - for (const { wallet, priority } of priorities) { - if (!isConfigured(wallet)) { - throw new Error(`cannot set priority for unconfigured wallet: ${wallet.def.name}`) - } - - if (wallet.config?.id) { - // set priority on server if it has an id - await setWalletPriority({ variables: { id: wallet.config.id, priority } }) - } else { - const storageKey = getStorageKey(wallet.def.name, me?.id) - const config = window.localStorage.getItem(storageKey) - const newConfig = { ...JSON.parse(config), priority } - window.localStorage.setItem(storageKey, JSON.stringify(newConfig)) - } - } - // reload local wallets if any priorities were set - if (priorities.length > 0) { - reloadLocalWallets() - } - }, [setWalletPriority, me?.id, reloadLocalWallets]) - - // provides priority sorted wallets to children, a function to reload local wallets, - // and a function to set priorities - const value = useMemo(() => ({ - wallets, - loading, - reloadLocalWallets, - setPriorities, - onVaultKeySet: syncLocalWallets, - beforeDisconnectVault: unsyncLocalWallets, - removeLocalWallets - }), [wallets, loading, reloadLocalWallets, setPriorities, syncLocalWallets, unsyncLocalWallets, removeLocalWallets]) - return ( - - - {children} - - - ) -} - -export function useWallets () { - return useContext(WalletsContext) -} - -export function useWallet (name) { - const { wallets } = useWallets() - return wallets.find(w => w.def.name === name) -} - -export function useConfiguredWallets () { - const { wallets, loading } = useWallets() - return useMemo(() => ({ - wallets: wallets.filter(w => isConfigured(w)), - loading - }), [wallets, loading]) -} - -export function useSendWallets () { - const { wallets } = useWallets() - // return all enabled wallets that are available and can send - return useMemo(() => wallets - .filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config?.enabled && canSend(w)), [wallets]) -} - -function RetryHandler ({ children }) { - const wallets = useSendWallets() - const waitForWalletPayment = useWalletPayment() - const invoiceHelper = useInvoice() - const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' }) - const { me } = useMe() - - const retry = useCallback(async (invoice) => { - const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true }) - - try { - await waitForWalletPayment(newInvoice) - } catch (err) { - if (err instanceof WalletConfigurationError) { - // consume attempt by canceling invoice - await invoiceHelper.cancel(newInvoice) - } - throw err - } - }, [invoiceHelper, waitForWalletPayment]) - - useEffect(() => { - // we always retry failed invoices, even if the user has no wallets on any client - // to make sure that failed payments will always show up in notifications eventually - - if (!me) return - - const retryPoll = async () => { - let failedInvoices - try { - const { data, error } = await getFailedInvoices() - if (error) throw error - failedInvoices = data.failedInvoices - } catch (err) { - console.error('failed to fetch invoices to retry:', err) - return - } - - for (const inv of failedInvoices) { - try { - await retry(inv) - } catch (err) { - // some retries are expected to fail since only one client at a time is allowed to retry - // these should show up as 'invoice not found' errors - console.error('retry failed:', err) - } - } - } - - let timeout, stopped - const queuePoll = () => { - timeout = setTimeout(async () => { - try { - await retryPoll() - } catch (err) { - // every error should already be handled in retryPoll - // but this catch is a safety net to not trigger an unhandled promise rejection - console.error('retry poll failed:', err) - } - if (!stopped) queuePoll() - }, NORMAL_POLL_INTERVAL) - } - - const stopPolling = () => { - stopped = true - clearTimeout(timeout) - } - - queuePoll() - return stopPolling - }, [me?.id, wallets, getFailedInvoices, retry]) - - return children -} diff --git a/wallets/indicator.js b/wallets/indicator.js deleted file mode 100644 index a4e7365f..00000000 --- a/wallets/indicator.js +++ /dev/null @@ -1,6 +0,0 @@ -import { useConfiguredWallets } from '@/wallets' - -export function useWalletIndicator () { - const { wallets, loading } = useConfiguredWallets() - return !loading && wallets.length === 0 -} diff --git a/wallets/lib/protocols/blink.js b/wallets/lib/protocols/blink.js new file mode 100644 index 00000000..5d25a661 --- /dev/null +++ b/wallets/lib/protocols/blink.js @@ -0,0 +1,137 @@ +import { string } from 'yup' +import { fetchWithTimeout } from '@/lib/fetch' +import { assertContentTypeJson, assertResponseOk } from '@/lib/url' + +// Blink +// http://blink.sv/ + +export const galoyBlinkUrl = 'https://api.blink.sv/graphql' +export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' + +export const SCOPE_READ = 'READ' +export const SCOPE_WRITE = 'WRITE' +export const SCOPE_RECEIVE = 'RECEIVE' + +const blinkApiKeyValidator = string().matches(/^blink_[A-Za-z0-9]+$/, 'must match pattern blink_A-Za-z0-9') +const blinkCurrencyValidator = string().oneOf(['BTC', 'USD']) + +export default [ + { + name: 'BLINK', + displayName: 'API', + send: true, + fields: [ + { + name: 'apiKey', + type: 'password', + label: 'api key', + placeholder: 'blink_...', + help: [ + `Generate an API key in your [Blink Dashboard](${galoyBlinkDashboardUrl}) with the following scopes:`, + '- READ', + '- WRITE' + ], + validate: blinkApiKeyValidator, + required: true, + encrypt: true + }, + { + name: 'currency', + label: 'currency', + type: 'text', + placeholder: 'BTC or USD', + required: true, + validate: blinkCurrencyValidator, + encrypt: true + } + ], + relationName: 'walletSendBlink' + }, + { + name: 'BLINK', + displayName: 'API', + send: false, + fields: [ + { + name: 'apiKey', + type: 'password', + label: 'api key', + placeholder: 'blink_...', + help: [ + `Generate an API key in your [Blink Dashboard](${galoyBlinkDashboardUrl}) with the following scopes:`, + '- READ', + '- RECEIVE' + ], + validate: blinkApiKeyValidator, + required: true + }, + { + name: 'currency', + label: 'currency', + type: 'text', + placeholder: 'BTC or USD', + required: true, + validate: blinkCurrencyValidator + } + ], + relationName: 'walletRecvBlink' + } +] + +export async function getWallet ({ apiKey, currency }, { signal }) { + const out = await request({ + apiKey, + query: ` + query me { + me { + defaultAccount { + wallets { + id + walletCurrency + } + } + } + }` + }, { signal }) + + const wallets = out.data.me.defaultAccount.wallets + for (const wallet of wallets) { + if (wallet.walletCurrency === currency) { + return wallet + } + } + + throw new Error(`wallet ${currency} not found`) +} + +export async function request ({ apiKey, query, variables = {} }, { signal }) { + const method = 'POST' + const res = await fetchWithTimeout(galoyBlinkUrl, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': apiKey + }, + body: JSON.stringify({ query, variables }), + signal + }) + + assertResponseOk(res, { method }) + assertContentTypeJson(res, { method }) + + return res.json() +} + +export async function getScopes ({ apiKey }, { signal }) { + const out = await request({ + apiKey, + query: ` + query scopes { + authorization { + scopes + } + }` + }, { signal }) + const scopes = out?.data?.authorization?.scopes + return scopes || [] +} diff --git a/wallets/lib/protocols/clnRest.js b/wallets/lib/protocols/clnRest.js new file mode 100644 index 00000000..6e387765 --- /dev/null +++ b/wallets/lib/protocols/clnRest.js @@ -0,0 +1,51 @@ +import { certValidator, runeValidator, socketValidator } from '@/wallets/lib//validate' + +// Core Lightning REST API +// https://docs.corelightning.org/docs/rest + +export default { + name: 'CLN_REST', + displayName: 'CLNRest', + send: false, + fields: [ + { + name: 'socket', + label: 'rest host and port', + type: 'text', + placeholder: '55.5.555.55:3010', + hint: 'tor or clearnet', + required: true, + validate: socketValidator() + }, + { + name: 'rune', + label: 'invoice only rune', + type: 'password', + help: [ + 'We only accept runes that *only* allow `method=invoice`.', + 'Run this if you are on v23.08 to generate one:', + '```lightning-cli createrune restrictions=\'["method=invoice"]\'```', + 'Or this if you are on v24.11 or later:', + '```lightning-cli createrune restrictions=\'[["method=invoice"]]\'```', + '[see `createrune` documentation](https://docs.corelightning.org/reference/lightning-createrune#restriction-format)' + ], + placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', + validate: runeValidator({ method: 'invoice' }), + required: true, + hint: 'must be restricted to method=invoice' + }, + { + name: 'cert', + label: 'certificate', + type: 'password', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + hint: [ + 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + 'hex or base64 encoded' + ], + validate: certValidator(), + required: false + } + ], + relationName: 'walletRecvCLNRest' +} diff --git a/wallets/cln/ATTACH.md b/wallets/lib/protocols/docs/cln.md similarity index 93% rename from wallets/cln/ATTACH.md rename to wallets/lib/protocols/docs/cln.md index 43f797a8..f496eb69 100644 --- a/wallets/cln/ATTACH.md +++ b/wallets/lib/protocols/docs/cln.md @@ -1,4 +1,4 @@ -For testing cln as an attached receiving wallet, you'll need a rune and the cert. +To attach CLNRest as an receiving wallet protocol, you'll need a rune and the cert. # host and port diff --git a/wallets/lightning-address/ATTACH.md b/wallets/lib/protocols/docs/lnAddr.md similarity index 100% rename from wallets/lightning-address/ATTACH.md rename to wallets/lib/protocols/docs/lnAddr.md diff --git a/wallets/lnbits/ATTACH.md b/wallets/lib/protocols/docs/lnbits.md similarity index 100% rename from wallets/lnbits/ATTACH.md rename to wallets/lib/protocols/docs/lnbits.md diff --git a/wallets/lnc/ATTACH.md b/wallets/lib/protocols/docs/lnc.md similarity index 70% rename from wallets/lnc/ATTACH.md rename to wallets/lib/protocols/docs/lnc.md index 6118253c..f6a1d348 100644 --- a/wallets/lnc/ATTACH.md +++ b/wallets/lib/protocols/docs/lnc.md @@ -1,4 +1,4 @@ -For testing litd as an attached receiving wallet, you'll need a pairing phrase: +For testing Lightning Node Connect via litd as a sending wallet protocol, you'll need a pairing phrase: This can be done one of two ways: @@ -17,6 +17,12 @@ $ sndev cli litd sessions add --type custom --label --account_id ```', + '```litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```', + 'Grab the `pairing_secret_mnemonic` from the output and paste it here.' + ], + validate: bip39Validator(), + required: true, + encrypt: true, + editable: false + }, + { + name: 'serverHost', + encrypt: true + }, + { + name: 'localKey', + encrypt: true + }, + { + name: 'remoteKey', + encrypt: true + } + ], + relationName: 'walletSendLNC' +} diff --git a/wallets/lib/protocols/lndGrpc.js b/wallets/lib/protocols/lndGrpc.js new file mode 100644 index 00000000..20bd97a9 --- /dev/null +++ b/wallets/lib/protocols/lndGrpc.js @@ -0,0 +1,49 @@ +import { certValidator, invoiceMacaroonValidator, socketValidator } from '@/wallets/lib/validate' + +// LND gRPC API + +export default { + name: 'LND_GRPC', + displayName: 'gRPC', + send: false, + fields: [ + { + name: 'socket', + label: 'grpc host and port', + placeholder: '55.5.555.55:10001', + hint: 'tor or clearnet', + type: 'text', + validate: socketValidator(), + required: true + }, + { + name: 'macaroon', + label: 'invoice macaroon', + placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', + hint: 'hex or base64 encoded', + help: { + label: 'privacy tip', + text: [ + 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:', + '```lncli bakemacaroon invoices:write invoices:read```' + ] + }, + type: 'password', + validate: invoiceMacaroonValidator(), + required: true + }, + { + name: 'cert', + label: 'certificate', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + hint: [ + 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + 'hex or base64 encoded' + ], + type: 'password', + validate: certValidator(), + required: false + } + ], + relationName: 'walletRecvLNDGRPC' +} diff --git a/wallets/lib/protocols/nwc.js b/wallets/lib/protocols/nwc.js new file mode 100644 index 00000000..12a2a72f --- /dev/null +++ b/wallets/lib/protocols/nwc.js @@ -0,0 +1,71 @@ +import Nostr from '@/lib/nostr' +import { NDKNWCWallet } from '@nostr-dev-kit/ndk-wallet' +import { nwcUrlValidator, parseNwcUrl } from '@/wallets/lib/validate' + +// Nostr Wallet Connect (NIP-47) +// https://github.com/nostr-protocol/nips/blob/master/47.md + +export default [ + { + name: 'NWC', + send: true, + displayName: 'Nostr Wallet Connect', + fields: [ + { + name: 'url', + label: 'url', + placeholder: 'nostr+walletconnect://', + type: 'password', + required: true, + validate: nwcUrlValidator(), + encrypt: true + } + ], + relationName: 'walletSendNWC' + }, + { + name: 'NWC', + send: false, + displayName: 'Nostr Wallet Connect', + fields: [ + { + name: 'url', + label: 'url', + placeholder: 'nostr+walletconnect://', + type: 'text', + required: true, + validate: nwcUrlValidator() + } + ], + relationName: 'walletRecvNWC' + } +] + +export async function nwcTryRun (fun, { url }, { signal }) { + const nostr = new Nostr() + try { + const nwc = await getNwc(nostr, url, { signal }) + return await fun(nwc) + } catch (e) { + if (e.error) throw new Error(e.error.message || e.error.code) + throw e + } finally { + nostr.close() + } +} + +export async function getNwc (nostr, url, { signal }) { + const ndk = nostr.ndk + const { walletPubkey, secret, relayUrls } = parseNwcUrl(url) + const nwc = new NDKNWCWallet(ndk, { + pubkey: walletPubkey, + relayUrls, + secret + }) + return nwc +} + +export async function supportedMethods (url, { signal }) { + const result = await nwcTryRun(nwc => nwc.getInfo(), { url }, { signal }) + return result.methods +} diff --git a/wallets/lib/protocols/phoenixd.js b/wallets/lib/protocols/phoenixd.js new file mode 100644 index 00000000..4355c910 --- /dev/null +++ b/wallets/lib/protocols/phoenixd.js @@ -0,0 +1,62 @@ +import { hexValidator, urlValidator } from '@/wallets/lib/validate' + +// Phoenixd +// https://phoenix.acinq.co/server + +export default [ + { + name: 'PHOENIXD', + displayName: 'API', + send: true, + fields: [ + { + name: 'url', + type: 'text', + label: 'url', + validate: urlValidator('clearnet'), + required: true + }, + { + name: 'apiKey', + type: 'password', + label: 'api key', + help: [ + 'The primary password can be found as `http-password` in your phoenixd configuration file.', + 'The default location is ~/.phoenix/phoenix.conf.', + 'Read the [official documentation](https://phoenix.acinq.co/server/api#security) for more details.' + ], + validate: hexValidator(64), + required: true, + encrypt: true + } + ], + relationName: 'walletSendPhoenixd' + }, + { + name: 'PHOENIXD', + displayName: 'API', + send: false, + fields: [ + { + name: 'url', + type: 'text', + label: 'url', + validate: urlValidator('clearnet'), + required: true + }, + { + name: 'apiKey', + type: 'password', + label: 'api key', + help: [ + 'The secondary password can be found as `http-password-limited-access` in your phoenixd configuration file.', + 'The default location is ~/.phoenix/phoenix.conf.', + 'Read the [official documentation](https://phoenix.acinq.co/server/api#security) for more details.' + ], + validate: hexValidator(64), + required: true + } + ], + relationName: 'walletRecvPhoenixd' + } +] diff --git a/wallets/lib/protocols/webln.js b/wallets/lib/protocols/webln.js new file mode 100644 index 00000000..75d8d3c8 --- /dev/null +++ b/wallets/lib/protocols/webln.js @@ -0,0 +1,38 @@ +import { useEffect } from 'react' + +// WebLN +// https://webln.guide/ + +export default { + name: 'WEBLN', + displayName: 'WebLN', + send: true, + fields: [], + relationName: 'walletSendWebLN', + isAvailable: () => window?.weblnEnabled +} + +export function WebLnProvider ({ children }) { + useEffect(() => { + const onEnable = () => { + window.weblnEnabled = true + } + + const onDisable = () => { + window.weblnEnabled = false + } + + if (!window.webln) onDisable() + else onEnable() + + window.addEventListener('webln:enabled', onEnable) + // event is not fired by Alby browser extension but added here for sake of completeness + window.addEventListener('webln:disabled', onDisable) + return () => { + window.removeEventListener('webln:enabled', onEnable) + window.removeEventListener('webln:disabled', onDisable) + } + }, []) + + return children +} diff --git a/wallets/lib/util.js b/wallets/lib/util.js new file mode 100644 index 00000000..fbe12c44 --- /dev/null +++ b/wallets/lib/util.js @@ -0,0 +1,116 @@ +import * as yup from 'yup' +import wallets from '@/wallets/lib/wallets.json' +import protocols from '@/wallets/lib/protocols' + +function walletJson (name) { + return wallets.find(wallet => wallet.name === name) +} + +export function walletDisplayName (name) { + return walletJson(name)?.displayName || titleCase(name) +} + +export function walletImage (name) { + return walletJson(name)?.image +} + +export function walletLud16Domain (name) { + const url = walletJson(name)?.url + if (!url) return undefined + + return typeof url === 'string' ? new URL(url).hostname : url.lud16Domain +} + +function protocol ({ name, send }) { + return protocols.find(protocol => protocol.name === name && protocol.send === send) +} + +export function protocolDisplayName ({ name, send }) { + return protocol({ name, send })?.displayName || titleCase(name) +} + +export function protocolRelationName ({ name, send }) { + return protocol({ name, send })?.relationName +} + +export function reverseProtocolRelationName (relationName) { + return protocols.find(protocol => protocol.relationName.toLowerCase() === relationName.toLowerCase()) +} + +export function protocolClientSchema ({ name, send }) { + const fields = protocolFields({ name, send }) + const schema = yup.object(fields.reduce((acc, field) => + ({ + ...acc, + [field.name]: field.required ? field.validate.required('required') : field.validate + }), {})) + return schema +} + +export function protocolServerSchema ({ name, send }, { keyHash, ignoreKeyHash }) { + const fields = protocolFields({ name, send }) + const schema = yup.object(fields.reduce((acc, field) => { + if (field.encrypt) { + const ivSchema = yup.string().hex().length(24) + const valueSchema = yup.string().hex() + return { + ...acc, + [field.name]: yup.object({ + iv: field.required ? ivSchema.required('required') : ivSchema, + value: field.required ? valueSchema.required('required') : valueSchema, + ...(!ignoreKeyHash ? { keyHash: yup.string().required('required').equals([keyHash], `must be ${keyHash}`) } : {}) + }) + } + } + + return { + ...acc, + [field.name]: field.required ? field.validate.required('required') : field.validate + } + }, {})) + return schema +} + +export function protocolMutationName ({ name, send }) { + const relationName = protocolRelationName({ name, send }) + return `upsert${relationName.charAt(0).toUpperCase() + relationName.slice(1)}` +} + +export function protocolFields ({ name, send }) { + return protocol({ name, send })?.fields || [] +} + +export function protocolAvailable ({ name, send }) { + const { isAvailable } = protocol({ name, send }) + + if (typeof isAvailable === 'function') { + return isAvailable() + } + + return true +} + +export function isEncryptedField (protocol, key) { + const fields = protocolFields(protocol) + return fields.find(field => field.name === key && field.encrypt) +} + +export function urlify (name) { + return name.toLowerCase().replace(/_/g, '-') +} + +export function unurlify (urlName) { + return urlName.toUpperCase().replace(/-/g, '_') +} + +function titleCase (name) { + return name.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') +} + +export function isWallet (wallet) { + return !isTemplate(wallet) +} + +export function isTemplate (obj) { + return obj.__typename.endsWith('Template') +} diff --git a/wallets/lib/validate.js b/wallets/lib/validate.js new file mode 100644 index 00000000..4dc4e630 --- /dev/null +++ b/wallets/lib/validate.js @@ -0,0 +1,175 @@ +import bip39Words from '@/lib/bip39-words' +import { decodeRune } from '@/lib/cln' +import { B64_URL_REGEX } from '@/lib/format' +import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' +import { NOSTR_PUBKEY_HEX } from '@/lib/nostr' +import { TOR_REGEXP } from '@/lib/url' +import { lightningAddressValidator } from '@/lib/validate' +import { string, array } from 'yup' + +export const externalLightningAddressValidator = lightningAddressValidator + .test({ + name: 'address', + test: addr => !addr.toLowerCase().endsWith('@stacker.news'), + message: 'lightning address must be external' + }) + +export const nwcUrlValidator = () => + string() + .url() + .test({ + test: (url, context) => { + if (!url) return true + + // run validation in sequence to control order of errors + // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 + try { + string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(url) + let relayUrls, walletPubkey, secret + try { + ({ relayUrls, walletPubkey, secret } = parseNwcUrl(url)) + } catch { + // invalid URL error. handle as if pubkey validation failed to not confuse user. + throw new Error('pubkey must be 64 hex chars') + } + string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) + array().of( + string().required('relay url required').trim().wss('relay must use wss://') + ).min(1, 'at least one relay required').validateSync(relayUrls) + string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + } + }) + +export function parseNwcUrl (walletConnectUrl) { + if (!walletConnectUrl) return {} + + walletConnectUrl = walletConnectUrl + .replace('nostrwalletconnect://', 'http://') + .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) + + // XXX There is a bug in parsing since we use the URL constructor for parsing: + // A wallet pubkey matching /^[0-9a-fA-F]{64}$/ might not be a valid hostname. + // Example: 11111111111 (10 1's) is a valid hostname (gets parsed as IPv4) but 111111111111 (11 1's) is not. + // See https://stackoverflow.com/questions/56804936/how-does-only-numbers-in-url-resolve-to-a-domain + // However, this seems to only get triggered if a wallet pubkey only contains digits so this is pretty improbable. + const url = new URL(walletConnectUrl) + const params = {} + params.walletPubkey = url.host + const secret = url.searchParams.get('secret') + const relayUrls = url.searchParams.getAll('relay') + if (secret) { + params.secret = secret + } + if (relayUrls) { + params.relayUrls = relayUrls + } + return params +} + +export const socketValidator = (msg = 'invalid socket') => + string() + .test({ + name: 'socket', + message: msg, + test: value => { + try { + const url = new URL(`http://${value}`) + return url.hostname && url.port && !url.username && !url.password && + (!url.pathname || url.pathname === '/') && !url.search && !url.hash + } catch (e) { + return false + } + }, + exclusive: false + }) + +export const runeValidator = ({ method }) => + string() + .matches(B64_URL_REGEX, { message: 'invalid rune' }) + .test({ + name: 'rune', + test: (v, context) => { + const decoded = decodeRune(v) + if (!decoded) return context.createError({ message: 'invalid rune' }) + if (decoded.restrictions.length === 0) { + return context.createError({ message: `rune must be restricted to method=${method}` }) + } + if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { + return context.createError({ message: `rune must be restricted to method=${method} only` }) + } + if (decoded.restrictions[0].alternatives[0] !== `method=${method}`) { + return context.createError({ message: `rune must be restricted to method=${method} only` }) + } + return true + } + }) + +export const invoiceMacaroonValidator = () => + string() + .hexOrBase64() + .test({ + name: 'macaroon', + test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), + message: 'not an invoice macaroon or an invoicable macaroon' + }) + +export const bip39Validator = () => + string() + .test({ + name: 'bip39', + test: async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + if (words.length < 2) { + return context.createError({ message: 'needs at least two words' }) + } + if (words.length > 10) { + return context.createError({ message: 'max 10 words' }) + } + return true + } + }) + +export const certValidator = () => string().hexOrBase64() + +export const urlValidator = (...args) => + process.env.NODE_ENV === 'development' + ? string() + .or([ + string().matches(/^(http:\/\/)?localhost:\d+$/), + string().url() + ], 'invalid url') + .trim() + : string().url().trim() + .test(async (url, context) => { + if (args.includes('tor') && TOR_REGEXP.test(url)) { + // allow HTTP and HTTPS over Tor + if (!/^https?:\/\//.test(url)) { + return context.createError({ message: 'http or https required' }) + } + return true + } + + if (args.includes('clearnet')) { + try { + // force HTTPS over clearnet + await string().https().validate(url) + } catch (err) { + return context.createError({ message: err.message }) + } + } + + return true + }) + +export const hexValidator = (length) => string().hex().length(length, `must be exactly ${length} hex chars`) diff --git a/wallets/lib/wallets.json b/wallets/lib/wallets.json new file mode 100644 index 00000000..03061d45 --- /dev/null +++ b/wallets/lib/wallets.json @@ -0,0 +1,161 @@ +[ + { + "name": "BLINK", + "displayName": "Blink", + "image": "/wallets/blink.svg", + "url": "https://blink.sv/" + }, + { + "name": "CLN", + "displayName": "Core Lightning", + "image": "/wallets/cln.svg", + "url": "https://corelightning.org/" + }, + { + "name": "LNBITS", + "displayName": "LNbits", + "image": "/wallets/lnbits.svg", + "url": "https://lnbits.com/" + }, + { + "name": "LND", + "displayName": "Lightning Network Daemon", + "image": "/wallets/lnd.png", + "url": "https://docs.lightning.engineering/lightning-network-tools/lnd" + }, + { + "name": "PHOENIXD", + "displayName": "Phoenixd", + "image": "/wallets/phoenixd.png", + "url": "https://phoenix.acinq.co/server" + }, + { + "name": "ZEUS", + "displayName": "Zeus", + "image": "/wallets/zeus.svg", + "url": { + "wallet": "https://zeusln.com/", + "lud16Domain": "zeuspay.com" + } + }, + { + "name": "COINOS", + "displayName": "Coinos", + "image": "/wallets/coinos.svg", + "url": "https://coinos.io/" + }, + { + "name": "PRIMAL", + "displayName": "Primal", + "image": "/wallets/primal.svg", + "url": "https://primal.net/" + }, + { + "name": "WALLET_OF_SATOSHI", + "displayName": "Wallet of Satoshi", + "image": "/wallets/wos.svg", + "url": "https://walletofsatoshi.com/" + }, + { + "name": "VOLTAGE", + "displayName": "Voltage", + "image": "/wallets/voltage.svg", + "url": { + "wallet": "https://www.voltage.cloud/", + "lud16Domain": "vlt.ge" + } + }, + { + "name": "RIZFUL", + "displayName": "Rizful", + "image": "/wallets/rizful.png", + "url": "https://rizful.com/" + }, + { + "name": "SPEED", + "displayName": "Speed", + "image": "/wallets/speed.svg", + "url": "https://tryspeed.com/" + }, + { + "name": "ZBD", + "displayName": "ZBD", + "image": "/wallets/zbd.png", + "url": "https://zbd.gg/" + }, + { + "name": "STRIKE", + "displayName": "Strike", + "image": "/wallets/strike.png", + "url": "https://strike.me/" + }, + { + "name": "MINIBITS", + "displayName": "Minibits", + "image": "/wallets/minibits.png", + "url": "https://minibits.cash/" + }, + { + "name": "FOUNTAIN", + "displayName": "Fountain", + "image": "/wallets/fountain.png", + "url": "https://fountain.fm/" + }, + { + "name": "LIFPAY", + "displayName": "Lifpay", + "image": "/wallets/lifpay.png", + "url": "https://lifpay.me/" + }, + { + "name": "SHOCKWALLET", + "displayName": "Shockwallet", + "image": "/wallets/shockwallet.png", + "url": "https://shockwallet.app/" + }, + { + "name": "ALBY", + "displayName": "Alby", + "image": "/wallets/alby.svg", + "url": "https://getalby.com/" + }, + { + "name": "BLIXT", + "displayName": "Blixt", + "image": "/wallets/blixt.svg", + "url": "https://blixtwallet.github.io/" + }, + { + "name": "NPUB_CASH", + "displayName": "npub.cash", + "image": "/wallets/npub-cash.svg", + "url": "https://npub.cash/" + }, + { + "name": "LN_ADDR", + "displayName": "Lightning Address", + "image": "/wallets/lnaddr.png", + "url": { + "wallet": "https://github.com/lnurl/luds/blob/luds/16.md", + "lud16Domain": null + } + }, + { + "name": "NWC", + "displayName": "Nostr Wallet Connect", + "image": "/wallets/nwc.png", + "url": "https://github.com/nostr-protocol/nips/blob/master/47.md" + }, + { + "name": "CASHU_ME", + "displayName": "Cashu.me", + "image": "/wallets/cashu.me.png", + "url": "https://cashu.me/" + }, + { + "name": "CASH_APP", + "displayName": "Cash App", + "image": "/wallets/cashapp.webp", + "url": "https://cash.app/" + } +] diff --git a/wallets/lightning-address/client.js b/wallets/lightning-address/client.js deleted file mode 100644 index 9c6b469e..00000000 --- a/wallets/lightning-address/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/lightning-address' diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js deleted file mode 100644 index 83d8689f..00000000 --- a/wallets/lightning-address/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import { externalLightningAddressValidator } from '@/lib/validate' - -export const name = 'lightning-address' -export const shortName = 'lnAddr' -export const walletType = 'LIGHTNING_ADDRESS' -export const walletField = 'walletLightningAddress' - -export const fields = [ - { - name: 'address', - label: 'lightning address', - type: 'text', - autoComplete: 'off', - serverOnly: true, - validate: externalLightningAddressValidator - } -] - -export const card = { - title: 'lightning address', - subtitle: 'receive zaps to your lightning address' -} diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js deleted file mode 100644 index 492086f7..00000000 --- a/wallets/lnbits/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import { TOR_REGEXP } from '@/lib/url' -import { string } from '@/lib/yup' - -export const name = 'lnbits' -export const walletType = 'LNBITS' -export const walletField = 'walletLNbits' - -export const fields = [ - { - name: 'url', - label: 'lnbits url', - type: 'text', - validate: process.env.NODE_ENV === 'development' - ? string() - .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') - .trim() - : string().url().trim() - .test(async (url, context) => { - if (TOR_REGEXP.test(url)) { - // allow HTTP and HTTPS over Tor - if (!/^https?:\/\//.test(url)) { - return context.createError({ message: 'http or https required' }) - } - return true - } - try { - // force HTTPS over clearnet - await string().https().validate(url) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - }) - }, - { - name: 'invoiceKey', - label: 'invoice key', - type: 'password', - optional: 'for receiving', - serverOnly: true, - requiredWithout: 'adminKey', - validate: string().hex().length(32) - }, - { - name: 'adminKey', - label: 'admin key', - type: 'password', - optional: 'for sending', - clientOnly: true, - requiredWithout: 'invoiceKey', - validate: string().hex().length(32) - } -] - -export const card = { - title: 'LNbits', - subtitle: 'use [LNbits](https://lnbits.com/) for payments', - image: { src: '/wallets/lnbits.svg' } -} diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js deleted file mode 100644 index c039678b..00000000 --- a/wallets/lnc/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import bip39Words from '@/lib/bip39-words' -import { string } from '@/lib/yup' - -export const name = 'lnc' -export const walletType = 'LNC' -export const walletField = 'walletLNC' - -export const fields = [ - { - name: 'pairingPhrase', - label: 'pairing phrase', - type: 'password', - help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', - editable: false, - clientOnly: true, - validate: string() - .test(async (value, context) => { - const words = value ? value.trim().split(/[\s]+/) : [] - for (const w of words) { - try { - await string().oneOf(bip39Words).validate(w) - } catch { - return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) - } - } - if (words.length < 2) { - return context.createError({ message: 'needs at least two words' }) - } - if (words.length > 10) { - return context.createError({ message: 'max 10 words' }) - } - return true - }) - }, - { - name: 'localKey', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - }, - { - name: 'remoteKey', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - }, - { - name: 'serverHost', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - } -] - -export const card = { - title: 'LNC', - subtitle: 'use Lightning Node Connect for LND payments' -} diff --git a/wallets/lnd/client.js b/wallets/lnd/client.js deleted file mode 100644 index e8e85d29..00000000 --- a/wallets/lnd/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/lnd' diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js deleted file mode 100644 index f47b451e..00000000 --- a/wallets/lnd/index.js +++ /dev/null @@ -1,54 +0,0 @@ -import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' -import { string } from '@/lib/yup' - -export const name = 'lnd' -export const walletType = 'LND' -export const walletField = 'walletLND' - -export const fields = [ - { - name: 'socket', - label: 'grpc host and port', - type: 'text', - placeholder: '55.5.555.55:10001', - hint: 'tor or clearnet', - clear: true, - serverOnly: true, - validate: string().socket() - }, - { - name: 'macaroon', - label: 'invoice macaroon', - help: { - label: 'privacy tip', - text: 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```' - }, - type: 'text', - placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64().test({ - name: 'macaroon', - test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), - message: 'not an invoice macaroon or an invoicable macaroon' - }) - }, - { - name: 'cert', - label: 'cert', - type: 'text', - placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', - optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64() - } -] - -export const card = { - title: 'LND', - subtitle: 'receive zaps to your Lightning Labs node', - image: { src: '/wallets/lnd.png' } -} diff --git a/wallets/logger.js b/wallets/logger.js deleted file mode 100644 index 8cf943f8..00000000 --- a/wallets/logger.js +++ /dev/null @@ -1,360 +0,0 @@ -import { useCallback, useMemo, useState, useEffect, useRef } from 'react' -import { decode as bolt11Decode } from 'bolt11' -import { formatMsats } from '@/lib/format' -import { walletTag, getWalletByType } from '@/wallets/common' -import { useMe } from '@/components/me' -import useIndexedDB, { getDbName } from '@/components/use-indexeddb' -import { useShowModal } from '@/components/modal' -import LogMessage from '@/components/log-message' -import { useToast } from '@/components/toast' -import { useMutation, useLazyQuery, gql } from '@apollo/client' -import { useRouter } from 'next/router' -import { WALLET_LOGS } from '@/fragments/wallet' -import { SSR } from '@/lib/constants' -import { Button } from 'react-bootstrap' -import styles from '@/styles/log.module.css' - -const INDICES = [ - { name: 'ts', keyPath: 'ts' }, - { name: 'wallet_ts', keyPath: ['wallet', 'ts'] } -] - -export function useWalletLoggerFactory () { - const { appendLog } = useWalletLogManager() - - const log = useCallback((wallet, level) => (message, context = {}) => { - if (!wallet) { - return - } - - if (context?.bolt11) { - // automatically populate context from bolt11 to avoid duplicating this code - const decoded = bolt11Decode(context.bolt11) - context = { - ...context, - amount: formatMsats(decoded.millisatoshis), - payment_hash: decoded.tagsObject.payment_hash, - description: decoded.tagsObject.description, - created_at: new Date(decoded.timestamp * 1000).toISOString(), - expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(), - // payments should affect wallet status - status: true - } - } - context.send = true - - appendLog(wallet, level, message, context) - console[level !== 'error' ? 'info' : 'error'](`[${walletTag(wallet.def)}]`, message) - }, [appendLog]) - - return useCallback(wallet => ({ - ok: (message, context) => log(wallet, 'ok')(message, context), - info: (message, context) => log(wallet, 'info')(message, context), - error: (message, context) => log(wallet, 'error')(message, context) - }), [log]) -} - -export function useWalletLogger (wallet) { - const factory = useWalletLoggerFactory() - return factory(wallet) -} - -export function WalletLogs ({ wallet, embedded }) { - const { logs, setLogs, hasMore, loadMore, loading } = useWalletLogs(wallet) - - const showModal = useShowModal() - - return ( - <> -
- { - showModal(onClose => ) - }} - >clear logs - -
-
- - - - - - - - - - {logs.map((log, i) => ( - - ))} - -
- {loading - ?
loading...
- : logs.length === 0 &&
empty
} - {hasMore - ?
- :
------ start of logs ------
} -
- - ) -} - -function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) { - const { deleteLogs } = useWalletLogManager(setLogs) - const toaster = useToast() - - let prompt = 'Do you really want to delete all wallet logs?' - if (wallet) { - prompt = 'Do you really want to delete all logs of this wallet?' - } - - return ( -
- {prompt} -
- cancel - -
-
- ) -} - -export function useWalletLogManager (setLogs) { - const { add, clear, notSupported } = useWalletLogDB() - - const appendLog = useCallback(async (wallet, level, message, context) => { - const log = { wallet: walletTag(wallet.def), level, message, ts: +new Date(), context } - try { - if (notSupported) { - console.log('cannot persist wallet log: indexeddb not supported') - } else { - await add(log) - } - setLogs?.(prevLogs => [log, ...prevLogs]) - } catch (error) { - console.error('Failed to append wallet log:', error) - } - }, [add, notSupported]) - - const [deleteServerWalletLogs] = useMutation( - gql` - mutation deleteWalletLogs($wallet: String) { - deleteWalletLogs(wallet: $wallet) - } - `, - { - onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== walletTag(getWalletByType(walletType)) : false)) - } - } - ) - - const deleteLogs = useCallback(async (wallet, options) => { - if ((!wallet || wallet.def.walletType) && !options?.clientOnly) { - await deleteServerWalletLogs({ variables: { wallet: wallet?.def.walletType } }) - } - if (!wallet || wallet.def.sendPayment) { - try { - const tag = wallet ? walletTag(wallet.def) : null - if (notSupported) { - console.log('cannot clear wallet logs: indexeddb not supported') - } else { - await clear('wallet_ts', tag ? window.IDBKeyRange.bound([tag, 0], [tag, Infinity]) : null) - } - setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag : false)) - } catch (e) { - console.error('failed to delete logs', e) - } - } - }, [clear, deleteServerWalletLogs, setLogs, notSupported]) - - return { appendLog, deleteLogs } -} - -export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { - const [logs, _setLogs] = useState([]) - const [page, setPage] = useState(initialPage) - const [hasMore, setHasMore] = useState(true) - const [cursor, setCursor] = useState(null) - const [loading, setLoading] = useState(true) - const latestTimestamp = useRef() - const { me } = useMe() - const router = useRouter() - - const { getPage, error, notSupported } = useWalletLogDB() - const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' }) - - const setLogs = useCallback((action) => { - _setLogs(action) - // action can be a React state dispatch function - const newLogs = typeof action === 'function' ? action(logs) : action - // make sure 'more' button is removed if logs were deleted - if (newLogs.length === 0) setHasMore(false) - latestTimestamp.current = newLogs[0]?.ts - }, [logs, _setLogs, setHasMore]) - - const loadLogsPage = useCallback(async (page, pageSize, walletDef, variables = {}) => { - try { - let result = { data: [], hasMore: false } - if (notSupported) { - console.log('cannot get client wallet logs: indexeddb not supported') - } else { - const indexName = walletDef ? 'wallet_ts' : 'ts' - const query = walletDef ? window.IDBKeyRange.bound([walletTag(walletDef), -Infinity], [walletTag(walletDef), Infinity]) : null - - result = await getPage(page, pageSize, indexName, query, 'prev') - // if given wallet has no walletType it means logs are only stored in local IDB - if (walletDef && !walletDef.walletType) { - return result - } - } - - const oldestTs = result?.data[result.data.length - 1]?.ts // start of local logs - const newestTs = result?.data[0]?.ts // end of local logs - - let from - if (variables?.from !== undefined) { - from = variables.from - } else if (oldestTs && result.hasMore) { - // fetch all missing, intertwined server logs since start of local logs - from = String(oldestTs) - } else { - from = null - } - - let to - if (variables?.to !== undefined) { - to = variables.to - } else if (newestTs && cursor) { - // fetch next old page of server logs - // ( if cursor is available, we will use decoded time of cursor ) - to = String(newestTs) - } else { - to = null - } - - const { data } = await getWalletLogs({ - variables: { - type: walletDef?.walletType, - from, - to, - cursor, - ...variables - } - }) - - const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({ - ts: +new Date(createdAt), - wallet: walletTag(getWalletByType(walletType)), - ...log, - // required to resolve recv status - context: { - recv: true, - status: !!log.context?.bolt11 && ['warn', 'error', 'success'].includes(log.level.toLowerCase()), - ...log.context - } - })) - const combinedLogs = uniqueSort([...result.data, ...newLogs]) - - setCursor(data.walletLogs.cursor) - return { - ...result, - data: combinedLogs, - hasMore: result.hasMore || !!data.walletLogs.cursor - } - } catch (error) { - console.error('Error loading logs from IndexedDB:', error) - return { data: [], hasMore: false } - } - }, [getPage, setCursor, cursor, notSupported]) - - if (error) { - console.error('IndexedDB error:', error) - } - - const loadMore = useCallback(async () => { - if (hasMore) { - setLoading(true) - const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def) - setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data])) - setHasMore(result.hasMore) - setPage(prevPage => prevPage + 1) - setLoading(false) - } - }, [setLogs, loadLogsPage, page, logsPerPage, wallet?.def, hasMore]) - - const loadNew = useCallback(async () => { - const latestTs = latestTimestamp.current - const variables = { from: latestTs?.toString(), to: null } - const result = await loadLogsPage(1, logsPerPage, wallet?.def, variables) - setLoading(false) - _setLogs(prevLogs => uniqueSort([...result.data, ...prevLogs])) - if (!latestTs) { - // we only want to update the more button if we didn't fetch new logs since it is about old logs. - // we didn't fetch new logs if this is our first fetch (no newest timestamp available) - setHasMore(result.hasMore) - } - }, [wallet?.def, loadLogsPage]) - - useEffect(() => { - // only fetch new logs if we are on a page that uses logs - const needLogs = router.asPath.startsWith('/wallets') - if (!me || !needLogs) return - - let timeout - let stop = false - - const poll = async () => { - await loadNew().catch(console.error) - if (!stop) timeout = setTimeout(poll, 1_000) - } - - timeout = setTimeout(poll, 1_000) - - return () => { - stop = true - clearTimeout(timeout) - } - }, [me?.id, router.pathname, loadNew]) - - return { logs, hasMore: !loading && hasMore, loadMore, setLogs, loading } -} - -function uniqueSort (logs) { - return Array.from(new Set(logs.map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts) -} - -function getWalletLogDbName (userId) { - return getDbName(userId) -} - -function useWalletLogDB () { - const { me } = useMe() - // memoize the idb config to avoid re-creating it on every render - const idbConfig = useMemo(() => - ({ dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }), [me?.id]) - const { add, getPage, clear, error, notSupported } = useIndexedDB(idbConfig) - - return { add, getPage, clear, error, notSupported } -} diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js deleted file mode 100644 index 19391cc4..00000000 --- a/wallets/nwc/client.js +++ /dev/null @@ -1,14 +0,0 @@ -import { supportedMethods, nwcTryRun } from '@/wallets/nwc' -export * from '@/wallets/nwc' - -export async function testSendPayment ({ nwcUrl }, { signal }) { - const supported = await supportedMethods(nwcUrl, { signal }) - if (!supported.includes('pay_invoice')) { - throw new Error('pay_invoice not supported') - } -} - -export async function sendPayment (bolt11, { nwcUrl }, { signal }) { - const result = await nwcTryRun(nwc => nwc.lnPay({ pr: bolt11 }), { nwcUrl }, { signal }) - return result.preimage -} diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js deleted file mode 100644 index 4c6e3f63..00000000 --- a/wallets/nwc/index.js +++ /dev/null @@ -1,69 +0,0 @@ -import Nostr from '@/lib/nostr' -import { string } from '@/lib/yup' -import { parseNwcUrl } from '@/lib/url' -import { NDKNWCWallet } from '@nostr-dev-kit/ndk-wallet' - -export const name = 'nwc' -export const walletType = 'NWC' -export const walletField = 'walletNWC' - -export const fields = [ - { - name: 'nwcUrl', - label: 'connection', - type: 'password', - optional: 'for sending', - clientOnly: true, - requiredWithout: 'nwcUrlRecv', - validate: string().nwcUrl() - }, - { - name: 'nwcUrlRecv', - label: 'connection', - type: 'password', - optional: 'for receiving', - serverOnly: true, - requiredWithout: 'nwcUrl', - validate: string().nwcUrl() - } -] - -export const card = { - title: 'NWC', - subtitle: 'use Nostr Wallet Connect for payments' -} - -async function getNwc (nostr, nwcUrl, { signal }) { - const ndk = nostr.ndk - const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl) - const nwc = new NDKNWCWallet(ndk, { - pubkey: walletPubkey, - relayUrls, - secret - }) - return nwc -} - -/** - * Run a nwc function and throw if it errors - * (workaround to handle ambiguous NDK error handling) - * @param {function} fun - the nwc function to run - * @returns - the result of the nwc function - */ -export async function nwcTryRun (fun, { nwcUrl }, { signal }) { - const nostr = new Nostr() - try { - const nwc = await getNwc(nostr, nwcUrl, { signal }) - return await fun(nwc) - } catch (e) { - if (e.error) throw new Error(e.error.message || e.error.code) - throw e - } finally { - nostr.close() - } -} - -export async function supportedMethods (nwcUrl, { signal }) { - const result = await nwcTryRun(nwc => nwc.getInfo(), { nwcUrl }, { signal }) - return result.methods -} diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js deleted file mode 100644 index 4fe8c48a..00000000 --- a/wallets/nwc/server.js +++ /dev/null @@ -1,29 +0,0 @@ -import { supportedMethods, nwcTryRun } from '@/wallets/nwc' -export * from '@/wallets/nwc' - -export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) { - const supported = await supportedMethods(nwcUrlRecv, { signal }) - - const supports = (method) => supported.includes(method) - - if (!supports('make_invoice')) { - throw new Error('make_invoice not supported') - } - - const mustNotSupport = ['pay_invoice', 'multi_pay_invoice', 'pay_keysend', 'multi_pay_keysend'] - for (const method of mustNotSupport) { - if (supports(method)) { - throw new Error(`${method} must not be supported`) - } - } - - return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal }) -} - -export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { signal }) { - const result = await nwcTryRun( - nwc => nwc.req('make_invoice', { amount: msats, description, expiry }), - { nwcUrl: nwcUrlRecv }, { signal } - ) - return result.result.invoice -} diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js deleted file mode 100644 index ad7f19ee..00000000 --- a/wallets/phoenixd/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import { string } from '@/lib/yup' - -export const name = 'phoenixd' -export const walletType = 'PHOENIXD' -export const walletField = 'walletPhoenixd' - -// configure wallet fields -export const fields = [ - { - name: 'url', - label: 'url', - type: 'text', - validate: string().url().trim() - }, - { - name: 'primaryPassword', - label: 'primary password', - type: 'password', - optional: 'for sending', - help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', - clientOnly: true, - requiredWithout: 'secondaryPassword', - validate: string().length(64).hex() - }, - { - name: 'secondaryPassword', - label: 'secondary password', - type: 'password', - optional: 'for receiving', - help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', - serverOnly: true, - requiredWithout: 'primaryPassword', - validate: string().length(64).hex() - } -] - -// configure wallet card -export const card = { - title: 'phoenixd', - subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments', - image: { src: '/wallets/phoenixd.png' } -} diff --git a/wallets/server/index.js b/wallets/server/index.js new file mode 100644 index 00000000..39a796a5 --- /dev/null +++ b/wallets/server/index.js @@ -0,0 +1,3 @@ +export * from './logger' +export * from './wrap' +export * from './receive' diff --git a/wallets/server/logger.js b/wallets/server/logger.js new file mode 100644 index 00000000..4b9ab566 --- /dev/null +++ b/wallets/server/logger.js @@ -0,0 +1,72 @@ +import { formatMsats } from '@/lib/format' +import { parsePaymentRequest } from 'ln-service' + +export function walletLogger ({ + models, + protocolId, + userId, + invoiceId, + withdrawalId +}) { + // server implementation of wallet logger interface on client + const log = (level) => async (message, context = {}) => { + // if no timestamp is given, set createdAt to time when logger was called to keep logs in order + // since logs are created asynchronously and thus might get inserted out of order + // however, millisecond precision is not always enough ... + const createdAt = context?.createdAt ?? new Date() + + const updateStatus = ['OK', 'ERROR', 'WARNING'].includes(level) && (invoiceId || withdrawalId || context.bolt11 || context?.updateStatus) + delete context?.updateStatus + + try { + if (context.bolt11) { + // automatically populate context from bolt11 to avoid duplicating this code + // (this is needed because in some cases we want to log before we have an invoice or withdrawal id) + context = { + ...context, + ...await logContextFromBolt11(context.bolt11) + } + } + + await models.$transaction([ + models.walletLog.create({ + data: { + userId, + protocolId, + level, + message, + context, + invoiceId, + withdrawalId, + createdAt + } + }), + updateStatus && models.walletProtocol.update({ + where: { id: protocolId }, + data: { status: level } + }) + ].filter(Boolean)) + } catch (err) { + console.error('error creating wallet log:', err) + } + } + + return { + ok: (message, context) => log('OK')(message, context), + info: (message, context) => log('INFO')(message, context), + error: (message, context) => log('ERROR')(message, context), + warn: (message, context) => log('WARNING')(message, context) + } +} + +export async function logContextFromBolt11 (bolt11) { + const decoded = await parsePaymentRequest({ request: bolt11 }) + return { + bolt11, + amount: formatMsats(decoded.mtokens), + payment_hash: decoded.id, + created_at: decoded.created_at, + expires_at: decoded.expires_at, + description: decoded.description + } +} diff --git a/wallets/blink/server.js b/wallets/server/protocols/blink.js similarity index 95% rename from wallets/blink/server.js rename to wallets/server/protocols/blink.js index 0d1f2748..01d1b91b 100644 --- a/wallets/blink/server.js +++ b/wallets/server/protocols/blink.js @@ -1,22 +1,7 @@ -import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' +import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/lib/protocols/blink' import { msatsToSats } from '@/lib/format' -export * from '@/wallets/blink' -export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) { - const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal }) - if (!scopes.includes(SCOPE_READ)) { - throw new Error('missing READ scope') - } - if (scopes.includes(SCOPE_WRITE)) { - throw new Error('WRITE scope must not be present') - } - if (!scopes.includes(SCOPE_RECEIVE)) { - throw new Error('missing RECEIVE scope') - } - - currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' - return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) -} +export const name = 'BLINK' export async function createInvoice ( { msats, description, expiry }, @@ -61,3 +46,19 @@ export async function createInvoice ( return res.invoice.paymentRequest } + +export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) { + const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal }) + if (!scopes.includes(SCOPE_READ)) { + throw new Error('missing READ scope') + } + if (scopes.includes(SCOPE_WRITE)) { + throw new Error('WRITE scope must not be present') + } + if (!scopes.includes(SCOPE_RECEIVE)) { + throw new Error('missing RECEIVE scope') + } + + currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' + return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) +} diff --git a/wallets/cln/server.js b/wallets/server/protocols/clnRest.js similarity index 69% rename from wallets/cln/server.js rename to wallets/server/protocols/clnRest.js index 2916e944..6235cf9f 100644 --- a/wallets/cln/server.js +++ b/wallets/server/protocols/clnRest.js @@ -1,15 +1,12 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' -export * from '@/wallets/cln' - -export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { - return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal }) -} +export const name = 'CLN_REST' export const createInvoice = async ( { msats, description, expiry }, { socket, rune, cert }, - { signal }) => { + { signal } +) => { const inv = await clnCreateInvoice( { msats, @@ -25,3 +22,11 @@ export const createInvoice = async ( return inv.bolt11 } + +export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { + return await createInvoice( + { msats: 1000, expiry: 1, description: 'SN test invoice' }, + { socket, rune, cert }, + { signal } + ) +} diff --git a/wallets/server/protocols/index.js b/wallets/server/protocols/index.js new file mode 100644 index 00000000..26c292d9 --- /dev/null +++ b/wallets/server/protocols/index.js @@ -0,0 +1,60 @@ +import * as nwc from './nwc' +import * as lnbits from './lnbits' +import * as lnAddr from './lnAddr' +import * as clnRest from './clnRest' +import * as phoenixd from './phoenixd' +import * as blink from './blink' +import * as lndGrpc from './lndGrpc' + +export * from './util' + +/** + * @typedef {@import('@/wallets/lib/protocols').ProtocolName} ProtocolName + */ + +/** + * @typedef {Object} ServerWalletProtocol + * @property {ProtocolName} name - must match a protocol name in the database + * @property {ProtocolCreateInvoice} createInvoice - create a new invoice + * @property {ProtocolTestCreateInvoice} testCreateInvoice - create a test invoice + */ + +/** + * @callback ProtocolCreateInvoice + * @param {CreateInvoiceArgs} args - arguments for the invoice + * @param {Object} config - current protocol configuration + * @param {CreateInvoiceOptions} opts - additional options for the invoice request + * @returns {Promise} - bolt11 invoice + */ + +/** + * @typedef {Object} CreateInvoiceArgs + * @property {number} msats - payment amount in millisatoshis + * @property {string} description - payment description + * @property {number} expiry - expiry time in seconds + */ + +/** + * @typedef {Object} CreateInvoiceOptions + * @property {AbortSignal} signal - signal to abort the request + */ + +/** + * @callback ProtocolTestCreateInvoice + * @param {Object} config - current protocol configuration + * @param {CreateInvoiceOptions} opts - additional options for the invoice request + * @returns {Promise} - bolt11 invoice + */ + +/** @typedef {string} Bolt11 */ + +/** @type {ServerWalletProtocol[]} */ +export default [ + nwc, + lnbits, + lnAddr, + clnRest, + phoenixd, + blink, + lndGrpc +] diff --git a/wallets/lightning-address/server.js b/wallets/server/protocols/lnAddr.js similarity index 96% rename from wallets/lightning-address/server.js rename to wallets/server/protocols/lnAddr.js index e5aa3c94..b6828e6d 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/server/protocols/lnAddr.js @@ -3,11 +3,7 @@ import { msatsSatsFloor } from '@/lib/format' import { lnAddrOptions } from '@/lib/lnurl' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/lightning-address' - -export const testCreateInvoice = async ({ address }, { signal }) => { - return await createInvoice({ msats: undefined }, { address }, { signal }) -} +export const name = 'LN_ADDR' export const createInvoice = async ( { msats, description }, @@ -48,3 +44,7 @@ export const createInvoice = async ( return body.pr } + +export const testCreateInvoice = async ({ address }, { signal }) => { + return await createInvoice({ msats: undefined }, { address }, { signal }) +} diff --git a/wallets/lnbits/server.js b/wallets/server/protocols/lnbits.js similarity index 86% rename from wallets/lnbits/server.js rename to wallets/server/protocols/lnbits.js index c22ce8ae..cdf3b6c1 100644 --- a/wallets/lnbits/server.js +++ b/wallets/server/protocols/lnbits.js @@ -5,22 +5,18 @@ import { getAgent } from '@/lib/proxy' import { assertContentTypeJson } from '@/lib/url' import fetch from 'cross-fetch' -export * from '@/wallets/lnbits' - -export async function testCreateInvoice ({ url, invoiceKey }, { signal }) { - return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal }) -} +export const name = 'LNBITS' export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, invoiceKey }, + { url, apiKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', invoiceKey) + headers.append('X-Api-Key', apiKey) // lnbits doesn't support msats so we have to floor to nearest sat const sats = msatsToSats(msats) @@ -69,3 +65,11 @@ export async function createInvoice ( const payment = await res.json() return payment?.payment_request || payment?.bolt11 } + +export async function testCreateInvoice ({ url, apiKey }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url, apiKey }, + { signal } + ) +} diff --git a/wallets/lnd/server.js b/wallets/server/protocols/lndGrpc.js similarity index 95% rename from wallets/lnd/server.js rename to wallets/server/protocols/lndGrpc.js index 6a9a7108..8ab0551e 100644 --- a/wallets/lnd/server.js +++ b/wallets/server/protocols/lndGrpc.js @@ -3,11 +3,7 @@ import { authenticatedLndGrpc } from '@/lib/lnd' import { createInvoice as lndCreateInvoice } from 'ln-service' import { TOR_REGEXP } from '@/lib/url' -export * from '@/wallets/lnd' - -export const testCreateInvoice = async ({ cert, macaroon, socket }) => { - return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) -} +export const name = 'LND_GRPC' export const createInvoice = async ( { msats, description, descriptionHash, expiry }, @@ -17,9 +13,9 @@ export const createInvoice = async ( const isOnion = TOR_REGEXP.test(socket) const { lnd } = await authenticatedLndGrpc({ - cert, + socket, macaroon, - socket + cert }, isOnion) const invoice = await lndCreateInvoice({ @@ -37,3 +33,7 @@ export const createInvoice = async ( throw new Error(details) } } + +export const testCreateInvoice = async ({ cert, macaroon, socket }) => { + return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) +} diff --git a/wallets/server/protocols/nwc.js b/wallets/server/protocols/nwc.js new file mode 100644 index 00000000..d1f9fcfe --- /dev/null +++ b/wallets/server/protocols/nwc.js @@ -0,0 +1,20 @@ +import { nwcTryRun } from '@/wallets/lib/protocols/nwc' + +export const name = 'NWC' + +export async function createInvoice ({ msats, description, expiry }, { url }, { signal }) { + const result = await nwcTryRun( + nwc => nwc.req('make_invoice', { amount: msats, description, expiry }), + { url }, + { signal } + ) + return result.result.invoice +} + +export async function testCreateInvoice ({ url }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url }, + { signal } + ) +} diff --git a/wallets/phoenixd/server.js b/wallets/server/protocols/phoenixd.js similarity index 80% rename from wallets/phoenixd/server.js rename to wallets/server/protocols/phoenixd.js index 0e836044..a56cda3b 100644 --- a/wallets/phoenixd/server.js +++ b/wallets/server/protocols/phoenixd.js @@ -3,25 +3,18 @@ import { msatsToSats } from '@/lib/format' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/phoenixd' - -export async function testCreateInvoice ({ url, secondaryPassword }, { signal }) { - return await createInvoice( - { msats: 1000, description: 'SN test invoice', expiry: 1 }, - { url, secondaryPassword }, - { signal }) -} +export const name = 'PHOENIXD' export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, secondaryPassword }, + { url, apiKey }, { signal } ) { // https://phoenix.acinq.co/server/api#create-bolt11-invoice const path = '/createinvoice' const headers = new Headers() - headers.set('Authorization', 'Basic ' + Buffer.from(':' + secondaryPassword).toString('base64')) + headers.set('Authorization', 'Basic ' + Buffer.from(':' + apiKey).toString('base64')) headers.set('Content-type', 'application/x-www-form-urlencoded') const body = new URLSearchParams() @@ -46,3 +39,10 @@ export async function createInvoice ( const payment = await res.json() return payment.serialized } + +export async function testCreateInvoice ({ url, apiKey }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url, apiKey }, + { signal }) +} diff --git a/wallets/server/protocols/util.js b/wallets/server/protocols/util.js new file mode 100644 index 00000000..a92ebf03 --- /dev/null +++ b/wallets/server/protocols/util.js @@ -0,0 +1,13 @@ +import protocols from '@/wallets/server/protocols' + +function protocol (name) { + return protocols.find(protocol => protocol.name === name) +} + +export function protocolCreateInvoice ({ name }, args, config, opts) { + return protocol(name).createInvoice(args, config, opts) +} + +export function protocolTestCreateInvoice ({ name }, config, opts) { + return protocol(name).testCreateInvoice(config, opts) +} diff --git a/wallets/server.js b/wallets/server/receive.js similarity index 70% rename from wallets/server.js rename to wallets/server/receive.js index 529b70a6..48751cd4 100644 --- a/wallets/server.js +++ b/wallets/server/receive.js @@ -1,32 +1,16 @@ -// import server side wallets -import * as lnd from '@/wallets/lnd/server' -import * as cln from '@/wallets/cln/server' -import * as lnAddr from '@/wallets/lightning-address/server' -import * as lnbits from '@/wallets/lnbits/server' -import * as nwc from '@/wallets/nwc/server' -import * as phoenixd from '@/wallets/phoenixd/server' -import * as blink from '@/wallets/blink/server' - -// we import only the metadata of client side wallets -import * as lnc from '@/wallets/lnc' -import * as webln from '@/wallets/webln' - -import { walletLogger } from '@/api/resolvers/wallet' import { parsePaymentRequest } from 'ln-service' -import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' +import { formatMsats, formatSats, msatsToSats, toPositiveBigInt, toPositiveNumber } from '@/lib/format' import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { timeoutSignal, withTimeout } from '@/lib/time' -import { canReceive } from './common' -import wrapInvoice from './wrap' - -const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] -export default walletDefs +import { wrapInvoice } from '@/wallets/server/wrap' +import { walletLogger } from '@/wallets/server/logger' +import { protocolCreateInvoice } from '@/wallets/server/protocols' const MAX_PENDING_INVOICES_PER_WALLET = 25 export async function * createUserInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) { - // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { + // get the protocols in order of priority + const protocols = await getInvoiceableWallets(userId, { paymentAttempt, predecessorId, models @@ -34,8 +18,8 @@ export async function * createUserInvoice (userId, { msats, description, descrip msats = toPositiveNumber(msats) - for (const { def, wallet } of wallets) { - const logger = walletLogger({ wallet, models }) + for (const protocol of protocols) { + const logger = walletLogger({ protocolId: protocol.id, userId, models }) try { logger.info( @@ -45,10 +29,10 @@ export async function * createUserInvoice (userId, { msats, description, descrip let invoice try { - invoice = await walletCreateInvoice( - { wallet, def }, + invoice = await _protocolCreateInvoice( + protocol, { msats, description, descriptionHash, expiry }, - { logger, models }) + { models }) } catch (err) { throw new Error('failed to create invoice: ' + err.message) } @@ -71,10 +55,10 @@ export async function * createUserInvoice (userId, { msats, description, descrip } } - yield { invoice, wallet, logger } + yield { invoice, protocol, logger } } catch (err) { console.error('failed to create user invoice:', err) - logger.error(err.message, { status: true }) + logger.error(err.message, { updateStatus: true }) } } } @@ -83,7 +67,7 @@ export async function createWrappedInvoice (userId, { msats, feePercent, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models, me, lnd }) { // loop over all receiver wallet invoices until we successfully wrapped one - for await (const { invoice, logger, wallet } of createUserInvoice(userId, { + for await (const { invoice, logger, protocol } of createUserInvoice(userId, { // this is the amount the stacker will receive, the other (feePercent)% is our fee msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n, description, @@ -97,12 +81,12 @@ export async function createWrappedInvoice (userId, return { invoice, wrappedInvoice: wrappedInvoice.request, - wallet, + protocol, maxFee } } catch (e) { console.error('failed to wrap invoice:', e) - logger?.error('failed to wrap invoice: ' + e.message, { bolt11 }) + logger?.warn('failed to wrap invoice: ' + e.message, { bolt11 }) } } @@ -114,29 +98,29 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess // the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it // so it has not been updated yet. // if predecessorId is not provided, the subquery will be empty and thus no wallets are filtered out. - const wallets = await models.$queryRaw` + return await models.$queryRaw` SELECT - "Wallet".*, + "WalletProtocol".*, jsonb_build_object( 'id', "users"."id", 'hideInvoiceDesc', "users"."hideInvoiceDesc" ) AS "user" - FROM "Wallet" + FROM "WalletProtocol" + JOIN "Wallet" ON "WalletProtocol"."walletId" = "Wallet"."id" JOIN "users" ON "users"."id" = "Wallet"."userId" WHERE "Wallet"."userId" = ${userId} - AND "Wallet"."enabled" = true - AND "Wallet"."id" NOT IN ( + AND "WalletProtocol"."enabled" = true + AND "WalletProtocol"."send" = false + AND "WalletProtocol"."id" NOT IN ( WITH RECURSIVE "Retries" AS ( -- select the current failed invoice that we are currently retrying -- this failed invoice will be used to start the recursion SELECT "Invoice"."id", "Invoice"."predecessorId" FROM "Invoice" WHERE "Invoice"."id" = ${predecessorId} AND "Invoice"."actionState" = 'FAILED' - - UNION ALL - - -- recursive part: use predecessorId to select the previous invoice that failed in the chain + UNION ALL + -- recursive part: use predecessorId to select the previous invoice that failed in the chain -- until there is no more previous invoice SELECT "Invoice"."id", "Invoice"."predecessorId" FROM "Invoice" @@ -145,40 +129,36 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess AND "Invoice"."paymentAttempt" = ${paymentAttempt} ) SELECT - "InvoiceForward"."walletId" + "InvoiceForward"."protocolId" FROM "Retries" JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id" JOIN "Withdrawl" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" WHERE "Withdrawl"."status" IS DISTINCT FROM 'CONFIRMED' ) ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC` - - const walletsWithDefs = wallets.map(wallet => { - const w = walletDefs.find(w => w.walletType === wallet.type) - return { wallet, def: w } - }) - - return walletsWithDefs.filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) } -async function walletCreateInvoice ({ wallet, def }, { +async function _protocolCreateInvoice (protocol, { msats, description, descriptionHash, expiry = 360 }, { logger, models }) { // check for pending withdrawals + + // TODO(wallet-v2): make sure this still works as intended const pendingWithdrawals = await models.withdrawl.count({ where: { - walletId: wallet.id, + protocolId: protocol.id, status: null } }) // and pending forwards + // TODO(wallet-v2): make sure this still works as intended const pendingForwards = await models.invoiceForward.count({ where: { - walletId: wallet.id, + protocolId: protocol.id, invoice: { actionState: { notIn: PAID_ACTION_TERMINAL_STATES @@ -193,14 +173,15 @@ async function walletCreateInvoice ({ wallet, def }, { } return await withTimeout( - def.createInvoice( + protocolCreateInvoice( + protocol, { msats, - description: wallet.user.hideInvoiceDesc ? undefined : description, + description: protocol.user.hideInvoiceDesc ? undefined : description, descriptionHash, expiry }, - wallet.wallet, + protocol.config, { logger, signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) diff --git a/wallets/server/resolvers/index.js b/wallets/server/resolvers/index.js new file mode 100644 index 00000000..c1fa0b20 --- /dev/null +++ b/wallets/server/resolvers/index.js @@ -0,0 +1,16 @@ +import { resolvers as walletResolvers } from './wallet' +import { resolvers as protocolResolvers } from './protocol' + +export default { + ...walletResolvers, + ...protocolResolvers, + Query: { + ...walletResolvers.Query, + ...protocolResolvers.Query + }, + Mutation: { + ...walletResolvers.Mutation, + ...protocolResolvers.Mutation + } + +} diff --git a/wallets/server/resolvers/protocol.js b/wallets/server/resolvers/protocol.js new file mode 100644 index 00000000..4e2f865c --- /dev/null +++ b/wallets/server/resolvers/protocol.js @@ -0,0 +1,373 @@ +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { validateSchema } from '@/lib/validate' +import protocols from '@/wallets/lib/protocols' +import { protocolRelationName, isEncryptedField, protocolMutationName, protocolServerSchema } from '@/wallets/lib/util' +import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util' +import { protocolTestCreateInvoice } from '@/wallets/server/protocols' +import { timeoutSignal, withTimeout } from '@/lib/time' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' +import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' +import { logContextFromBolt11, walletLogger } from '@/wallets/server/logger' +import { formatMsats } from '@/lib/format' + +const WalletProtocolConfig = { + __resolveType: config => config.__resolveType +} + +const WalletLogEntry = { + context: async ({ level, context, withdrawal }) => { + const isError = ['error', 'warn'].includes(level.toLowerCase()) + + // never return invoice as context because it might leak sensitive sender details + if (withdrawal) { + return { + ...await logContextFromBolt11(withdrawal.bolt11), + ...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}), + ...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {}) + } + } + + return context + } +} + +export const resolvers = { + WalletProtocolConfig, + WalletLogEntry, + Query: { + walletLogs + }, + Mutation: { + ...Object.fromEntries( + protocols.map(protocol => { + return [ + protocolMutationName(protocol), + upsertWalletProtocol(protocol) + ] + }) + ), + addWalletLog, + removeWalletProtocol, + deleteWalletLogs + } +} + +export function upsertWalletProtocol (protocol) { + return async (parent, { + walletId, + templateName, + enabled, + networkTests = true, + ignoreKeyHash = false, + ...args + }, { me, models, tx }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (!walletId && !templateName) { + throw new GqlInputError('walletId or templateName is required') + } + + const { vaultKeyHash: existingKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + + const schema = protocolServerSchema(protocol, { keyHash: existingKeyHash, ignoreKeyHash }) + try { + await validateSchema(schema, args) + } catch (e) { + // TODO(wallet-v2): on length errors, error message includes path twice like this: + // "apiKey.iv: apiKey.iv must be exactly 32 characters" + throw new GqlInputError(e.message) + } + + if (!protocol.send && networkTests) { + let invoice + try { + invoice = await withTimeout( + protocolTestCreateInvoice(protocol, args, { signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) }), + WALLET_CREATE_INVOICE_TIMEOUT_MS + ) + } catch (e) { + throw new GqlInputError('failed to create test invoice: ' + e.message) + } + + if (!invoice || !invoice.startsWith('lnbc')) { + throw new GqlInputError('wallet returned invalid invoice') + } + } + + const relation = protocolRelationName(protocol) + + function dataFragment (args, type) { + return Object.fromEntries( + Object.entries(args).map( + ([key, value]) => { + if (isEncryptedField(protocol, key)) { + return [key, { [type]: { value: value.value, iv: value.iv } }] + } + return [key, value] + } + ) + ) + } + + // Prisma does not support nested transactions so we need to check manually if we were given a transaction + // https://github.com/prisma/prisma/issues/15212 + async function transaction (tx) { + if (templateName) { + const { id: newWalletId } = await tx.wallet.create({ + data: { + templateName, + userId: me.id + } + }) + walletId = newWalletId + } + + const wallet = await tx.wallet.update({ + where: { + id: Number(walletId), + // this makes sure that users can only update their own wallets + // (the update will fail in this case and abort the transaction) + userId: me.id + }, + data: { + protocols: { + upsert: { + where: { + WalletProtocol_walletId_send_name_key: { + walletId: Number(walletId), + send: protocol.send, + name: protocol.name + } + }, + update: { + enabled, + [relation]: { + update: dataFragment(args, 'update') + } + }, + create: { + enabled, + send: protocol.send, + name: protocol.name, + [relation]: { + create: dataFragment(args, 'create') + } + } + } + } + }, + include: { + protocols: true + } + }) + // XXX Prisma seems to run the vault update AFTER the update of the table that points to it + // which means our trigger to set the jsonb column in the WalletProtocol table does not see + // the updated vault entry. + // To fix this, we run another update to force the trigger to run again. + // TODO(wallet-v2): fix this in a better way? + await tx.walletProtocol.update({ + where: { + WalletProtocol_walletId_send_name_key: { + walletId: Number(walletId), + send: protocol.send, + name: protocol.name + } + }, + data: { + [relation]: { + update: { + updatedAt: new Date() + } + } + } + }) + + await updateWalletBadges({ userId: me.id, tx }) + + return mapWalletResolveTypes(wallet) + } + + return await (tx ? transaction(tx) : models.$transaction(transaction)) + } +} + +export async function removeWalletProtocol (parent, { id }, { me, models, tx }) { + if (!me) { + throw new GqlAuthenticationError() + } + + async function transaction (tx) { + // vault is deleted via trigger + const protocol = await tx.walletProtocol.delete({ + where: { + id: Number(id), + wallet: { + userId: me.id + } + } + }) + + const wallet = await tx.wallet.findUnique({ + where: { + id: protocol.walletId + }, + include: { + protocols: true + } + }) + if (wallet.protocols.length === 0) { + await tx.wallet.delete({ + where: { + id: wallet.id + } + }) + } + + await updateWalletBadges({ userId: me.id, tx }) + + return true + } + + return await (tx ? transaction(tx) : models.$transaction(transaction)) +} + +async function walletLogs (parent, { protocolId, cursor }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const decodedCursor = decodeCursor(cursor) + + const logs = await models.walletLog.findMany({ + where: { + userId: me.id, + protocolId, + createdAt: { + lt: decodedCursor.time + } + }, + orderBy: { + createdAt: 'desc' + }, + take: LIMIT, + skip: decodedCursor.offset, + include: { + protocol: { + include: { + wallet: { + include: { + template: true + } + } + } + }, + invoice: true, + withdrawal: true + } + }) + + return { + entries: logs.map(log => ({ + ...log, + ...(log.protocol + ? { + wallet: { + ...log.protocol.wallet, + name: log.protocol.wallet.template.name + } + } + : {}) + })), + cursor: logs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null + } +} + +async function addWalletLog (parent, { protocolId, level, message, timestamp, invoiceId }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const logger = walletLogger({ models, protocolId, userId: me.id, invoiceId }) + await logger[level.toLowerCase()](message, { createdAt: timestamp }) + + return true +} + +async function deleteWalletLogs (parent, { protocolId }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await models.walletLog.deleteMany({ + where: { + userId: me.id, + protocolId + } + }) + + return true +} + +async function updateWalletBadges ({ userId, tx }) { + const pushNotifications = [] + + const wallets = await tx.wallet.findMany({ + where: { + userId + }, + include: { + protocols: true + } + }) + + const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) + + const newHasRecvWallet = wallets.some(({ protocols }) => protocols.some(({ send, enabled }) => !send && enabled)) + const newHasSendWallet = wallets.some(({ protocols }) => protocols.some(({ send, enabled }) => send && enabled)) + + await tx.user.update({ + where: { id: userId }, + data: { + hasRecvWallet: newHasRecvWallet, + hasSendWallet: newHasSendWallet + } + }) + + const startStreak = async (type) => { + const streak = await tx.streak.create({ + data: { userId, type, startedAt: new Date() } + }) + return streak.id + } + + const endStreak = async (type) => { + const [streak] = await tx.$queryRaw` + UPDATE "Streak" + SET "endedAt" = now(), updated_at = now() + WHERE "userId" = ${userId} + AND "type" = ${type}::"StreakType" + AND "endedAt" IS NULL + RETURNING "id" + ` + return streak?.id + } + + if (!oldHasRecvWallet && newHasRecvWallet) { + const streakId = await startStreak('HORSE') + if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'HORSE', id: streakId })) + } + if (!oldHasSendWallet && newHasSendWallet) { + const streakId = await startStreak('GUN') + if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'GUN', id: streakId })) + } + + if (oldHasRecvWallet && !newHasRecvWallet) { + const streakId = await endStreak('HORSE') + if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'HORSE', id: streakId })) + } + if (oldHasSendWallet && !newHasSendWallet) { + const streakId = await endStreak('GUN') + if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'GUN', id: streakId })) + } + + // run all push notifications at the end to make sure we don't + // accidentally send push notifications even if transaction fails + Promise.all(pushNotifications.map(notify => notify())).catch(console.error) +} diff --git a/wallets/server/resolvers/util.js b/wallets/server/resolvers/util.js new file mode 100644 index 00000000..0155a422 --- /dev/null +++ b/wallets/server/resolvers/util.js @@ -0,0 +1,40 @@ +export function mapWalletResolveTypes (wallet) { + const resolveTypeOfProtocolConfig = ({ name, send }) => { + switch (name) { + case 'NWC': + return send ? 'WalletSendNWC' : 'WalletRecvNWC' + case 'LNBITS': + return send ? 'WalletSendLNbits' : 'WalletRecvLNbits' + case 'PHOENIXD': + return send ? 'WalletSendPhoenixd' : 'WalletRecvPhoenixd' + case 'BLINK': + return send ? 'WalletSendBlink' : 'WalletRecvBlink' + case 'WEBLN': + return 'WalletSendWebLN' + case 'LN_ADDR': + return 'WalletRecvLightningAddress' + case 'LNC': + return 'WalletSendLNC' + case 'CLN_REST': + return 'WalletRecvCLNRest' + case 'LND_GRPC': + return 'WalletRecvLNDGRPC' + default: + return null + } + } + + return { + ...wallet, + protocols: wallet.protocols.map(({ config, ...p }) => { + return { + ...p, + config: { + ...config, + __resolveType: resolveTypeOfProtocolConfig(p) + } + } + }), + __resolveType: 'Wallet' + } +} diff --git a/wallets/server/resolvers/wallet.js b/wallets/server/resolvers/wallet.js new file mode 100644 index 00000000..54f4bdf7 --- /dev/null +++ b/wallets/server/resolvers/wallet.js @@ -0,0 +1,227 @@ +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util' +import { removeWalletProtocol, upsertWalletProtocol } from './protocol' +import { validateSchema, walletSettingsSchema } from '@/lib/validate' + +const WalletOrTemplate = { + __resolveType: walletOrTemplate => walletOrTemplate.__resolveType +} + +const Wallet = { + name: wallet => wallet.template.name, + send: wallet => walletStatus(wallet, 'send'), + receive: wallet => walletStatus(wallet, 'receive') +} + +const WalletTemplate = { + send: walletTemplate => walletTemplate.sendProtocols.length > 0 ? 'OK' : 'DISABLED', + receive: walletTemplate => walletTemplate.recvProtocols.length > 0 ? 'OK' : 'DISABLED', + protocols: walletTemplate => { + return [ + ...walletTemplate.sendProtocols.map(protocol => ({ + id: `WalletTemplate-${walletTemplate.id}-${protocol}-send`, + name: protocol, + send: true + })), + ...walletTemplate.recvProtocols.map(protocol => ({ + id: `WalletTemplate-${walletTemplate.id}-${protocol}-recv`, + name: protocol, + send: false + })) + ] + } +} + +export const resolvers = { + WalletOrTemplate, + Wallet, + WalletTemplate, + Query: { + wallets, + wallet, + walletSettings + }, + Mutation: { + updateWalletEncryption, + updateKeyHash, + resetWallets, + setWalletPriorities, + disablePassphraseExport, + setWalletSettings + } +} + +async function wallets (parent, args, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + let wallets = await models.wallet.findMany({ + where: { + userId: me.id + }, + include: { + template: true, + protocols: true + }, + orderBy: [ + { priority: 'asc' }, + { id: 'asc' } + ] + }) + + let walletTemplates = await models.walletTemplate.findMany() + + wallets = wallets.map(mapWalletResolveTypes) + walletTemplates = walletTemplates.map(t => { + return { + ...t, + __resolveType: 'WalletTemplate' + } + }) + + return [...wallets, ...walletTemplates] +} + +async function wallet (parent, { id, name }, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + if (id) { + const wallet = await models.wallet.findUnique({ + where: { id: Number(id), userId: me.id }, + include: { + template: true, + protocols: true + } + }) + return mapWalletResolveTypes(wallet) + } + + const template = await models.walletTemplate.findUnique({ where: { name } }) + return { ...template, __resolveType: 'WalletTemplate' } +} + +function walletStatus (wallet, type) { + const protocols = wallet.protocols.filter(protocol => type === 'send' ? protocol.send : !protocol.send) + + const disabled = protocols.every(protocol => !protocol.enabled) + if (disabled) return 'DISABLED' + + const ok = protocols.every(protocol => protocol.status === 'OK') + if (ok) return 'OK' + + const error = protocols.every(protocol => protocol.status === 'ERROR') + if (error) return 'ERROR' + + return 'WARNING' +} + +async function walletSettings (parent, args, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + return await models.user.findUnique({ where: { id: me.id } }) +} + +async function updateWalletEncryption (parent, { keyHash, wallets }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + if (!keyHash) throw new GqlInputError('hash required') + + const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + + return await models.$transaction(async tx => { + for (const { id: walletId, protocols } of wallets) { + for (const { name, send, config } of protocols) { + const mutation = upsertWalletProtocol({ name, send }) + await mutation(parent, { walletId, networkTests: false, ignoreKeyHash: true, ...config }, { me, models: tx, tx }) + } + } + + // optimistic concurrency control: + // make sure the user's vault key didn't change while we were updating the protocols + await tx.user.update({ + where: { id: me.id, vaultKeyHash: oldKeyHash }, + data: { vaultKeyHash: keyHash, showPassphrase: false } + }) + + return true + }) +} + +async function updateKeyHash (parent, { keyHash }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const count = await models.$executeRaw` + UPDATE users + SET "vaultKeyHash" = ${keyHash} + WHERE id = ${me.id} + AND "vaultKeyHash" = '' + ` + + return count > 0 +} + +async function resetWallets (parent, { newKeyHash }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const { vaultKeyHash: oldHash } = await models.user.findUnique({ where: { id: me.id } }) + + await models.$transaction(async tx => { + const protocols = await tx.walletProtocol.findMany({ + where: { + send: true, + wallet: { + userId: me.id + } + } + }) + + for (const protocol of protocols) { + await removeWalletProtocol(parent, { id: protocol.id }, { me, tx }) + } + + await tx.user.update({ + where: { id: me.id, vaultKeyHash: oldHash }, + // TODO(wallet-v2): nullable vaultKeyHash column + data: { vaultKeyHash: newKeyHash, showPassphrase: true } + }) + }) + + return true +} + +async function disablePassphraseExport (parent, args, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await models.user.update({ where: { id: me.id }, data: { showPassphrase: false } }) + + return true +} + +async function setWalletPriorities (parent, { priorities }, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + await models.$transaction(async tx => { + for (const { id, priority } of priorities) { + await tx.wallet.update({ + where: { userId: me.id, id: Number(id) }, + data: { priority } + }) + } + }) + + return true +} + +async function setWalletSettings (parent, { settings }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await validateSchema(walletSettingsSchema, settings) + + await models.user.update({ where: { id: me.id }, data: settings }) + + return true +} diff --git a/wallets/wrap.js b/wallets/server/wrap.js similarity index 97% rename from wallets/wrap.js rename to wallets/server/wrap.js index a5079a10..c43b1a21 100644 --- a/wallets/wrap.js +++ b/wallets/server/wrap.js @@ -1,5 +1,5 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service' -import { estimateRouteFee, getBlockHeight } from '../api/lnd' +import { estimateRouteFee, getBlockHeight } from '@/api/lnd' import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format' const MIN_OUTGOING_MSATS = BigInt(700) // the minimum msats we'll allow for the outgoing invoice @@ -28,7 +28,7 @@ const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'l maxFee: number } */ -export default async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) { +export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) { try { console.group('wrapInvoice', description) diff --git a/wallets/status.js b/wallets/status.js deleted file mode 100644 index dc92cfd7..00000000 --- a/wallets/status.js +++ /dev/null @@ -1,54 +0,0 @@ -import { canReceive, canSend, isConfigured } from '@/wallets/common' -import { useWalletLogs } from '@/wallets/logger' -import styles from '@/styles/wallet.module.css' - -export const Status = { - Enabled: 'Enabled', - Disabled: 'Disabled', - Error: 'Error', - Warning: 'Warning' -} - -export function useWalletStatus (wallet) { - const { logs } = useWalletLogs(wallet) - - return statusFromLogs(wallet, { - any: wallet.config?.enabled && isConfigured(wallet) ? Status.Enabled : Status.Disabled, - send: wallet.config?.enabled && canSend(wallet) ? Status.Enabled : Status.Disabled, - recv: wallet.config?.enabled && canReceive(wallet) ? Status.Enabled : Status.Disabled - }, logs) -} - -const statusFromLogs = (wallet, status, logs) => { - if (status.any === Status.Disabled) return status - - // override status depending on if there have been warnings or errors in the logs recently - // find first log from which we can derive status (logs are sorted by recent first) - const walletLogs = logs.filter(l => l.wallet === wallet.def.name) - const sendLevel = walletLogs.find(l => l.context?.status && l.context?.send)?.level - const recvLevel = walletLogs.find(l => l.context?.status && l.context?.recv)?.level - - const levelToStatus = (level) => { - switch (level?.toLowerCase()) { - case 'ok': - case 'success': return Status.Enabled - case 'error': return Status.Error - case 'warn': return Status.Warning - } - } - - return { - any: status.any, - send: levelToStatus(sendLevel) || status.send, - recv: levelToStatus(recvLevel) || status.recv - } -} - -export const statusToClass = status => { - switch (status) { - case Status.Enabled: return styles.success - case Status.Disabled: return styles.disabled - case Status.Error: return styles.error - case Status.Warning: return styles.warning - } -} diff --git a/wallets/support.js b/wallets/support.js deleted file mode 100644 index 270d35c2..00000000 --- a/wallets/support.js +++ /dev/null @@ -1,8 +0,0 @@ -import { supportsReceive, supportsSend } from '@/wallets/common' - -export function useWalletSupport (wallet) { - return { - send: supportsSend(wallet), - recv: supportsReceive(wallet) - } -} diff --git a/wallets/validate.js b/wallets/validate.js deleted file mode 100644 index bea0cddd..00000000 --- a/wallets/validate.js +++ /dev/null @@ -1,117 +0,0 @@ -/* - we want to take all the validate members from the provided wallet - and compose into a single yup schema for formik validation ... - the validate member can be on of: - - a yup schema - - a function that throws on an invalid value - - a regular expression that must match -*/ - -import { autowithdrawSchemaMembers, vaultEntrySchema } from '@/lib/validate' -import * as Yup from '@/lib/yup' -import { canReceive } from './common' - -export default async function validateWallet (walletDef, data, - { yupOptions = { abortEarly: true }, topLevel = true, serverSide = false, skipGenerated = false } = {}) { - let schema = composeWalletSchema(walletDef, serverSide, skipGenerated) - - if (canReceive({ def: walletDef, config: data })) { - schema = schema.concat(autowithdrawSchemaMembers) - } - - await schema.validate(data, yupOptions) - - const casted = schema.cast(data, { assert: false }) - if (topLevel && walletDef.validate) { - await walletDef.validate(casted) - } - - return casted -} - -function createFieldSchema (name, validate) { - if (!validate) { - throw new Error(`No validation provided for field ${name}`) - } - - if (Yup.isSchema(validate)) { - // If validate is already a Yup schema, return it directly - return validate - } else if (typeof validate === 'function') { - // If validate is a function, create a custom Yup test - return Yup.mixed().test({ - name, - test: (value, context) => { - try { - validate(value) - return true - } catch (error) { - return context.createError({ message: error.message }) - } - } - }) - } else if (validate instanceof RegExp) { - // If validate is a regular expression, use Yup.matches - return Yup.string().matches(validate, `${name} is invalid`) - } else { - throw new Error(`validate for ${name} must be a yup schema, function, or regular expression`) - } -} - -function composeWalletSchema (walletDef, serverSide, skipGenerated) { - const { fields } = walletDef - - const vaultEntrySchemas = { required: [], optional: [] } - const cycleBreaker = [] - const schemaShape = fields.reduce((acc, field) => { - const { name, validate, optional, generated, clientOnly, requiredWithout } = field - - if (generated && skipGenerated) { - return acc - } - - if (clientOnly && serverSide) { - // For server-side validation, accumulate clientOnly fields as vaultEntries - vaultEntrySchemas[optional ? 'optional' : 'required'].push(vaultEntrySchema(name)) - } else { - acc[name] = createFieldSchema(name, validate) - - if (!optional) { - acc[name] = acc[name].required('required') - } else if (requiredWithout) { - const myName = serverSide ? 'vaultEntries' : name - const partnerName = serverSide ? 'vaultEntries' : requiredWithout - // if a cycle breaker between this pair hasn't been added yet, add it - if (!cycleBreaker.some(pair => pair[1] === myName)) { - cycleBreaker.push([myName, partnerName]) - } - // if we are the server, the pairSetting will be in the vaultEntries array - acc[name] = acc[name].when([partnerName], ([pairSetting], schema) => { - if (!pairSetting || (serverSide && !pairSetting.some(v => v.key === requiredWithout))) { - return schema.required(`required if ${requiredWithout} not set`) - } - return Yup.mixed().or([schema.test({ - test: value => value !== pairSetting, - message: `${name} cannot be the same as ${requiredWithout}` - }), Yup.mixed().notRequired()]) - }) - } - } - - return acc - }, {}) - - // Finalize the vaultEntries schema if it exists - if (vaultEntrySchemas.required.length > 0 || vaultEntrySchemas.optional.length > 0) { - schemaShape.vaultEntries = Yup.array().equalto(vaultEntrySchemas) - } - - // we use cycleBreaker to avoid cyclic dependencies in Yup schema - // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 - const composedSchema = Yup.object().shape(schemaShape, cycleBreaker).concat(Yup.object({ - enabled: Yup.boolean(), - priority: Yup.number().min(0, 'must be at least 0').max(100, 'must be at most 100') - })) - - return composedSchema -} diff --git a/wallets/webln/client.js b/wallets/webln/client.js deleted file mode 100644 index a54df4b3..00000000 --- a/wallets/webln/client.js +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect } from 'react' -import { SSR } from '@/lib/constants' -import { WalletError } from '../errors' -export * from '@/wallets/webln' - -export const sendPayment = async (bolt11) => { - if (typeof window.webln === 'undefined') { - throw new WalletError('WebLN provider not found') - } - - // this will prompt the user to unlock the wallet if it's locked - try { - await window.webln.enable() - } catch (err) { - throw new WalletError(err.message) - } - - // this will prompt for payment if no budget is set - const response = await window.webln.sendPayment(bolt11) - if (!response) { - // sendPayment returns nothing if WebLN was enabled - // but browser extension that provides WebLN was then disabled - // without reloading the page - throw new WalletError('sendPayment returned no response') - } - - return response.preimage -} - -export function isAvailable () { - return !SSR && window?.weblnEnabled -} - -export function WebLnProvider ({ children }) { - useEffect(() => { - const onEnable = () => { - window.weblnEnabled = true - } - - const onDisable = () => { - window.weblnEnabled = false - } - - if (!window.webln) onDisable() - else onEnable() - - window.addEventListener('webln:enabled', onEnable) - // event is not fired by Alby browser extension but added here for sake of completeness - window.addEventListener('webln:disabled', onDisable) - return () => { - window.removeEventListener('webln:enabled', onEnable) - window.removeEventListener('webln:disabled', onDisable) - } - }, []) - - return children -} diff --git a/wallets/webln/index.js b/wallets/webln/index.js deleted file mode 100644 index cce91750..00000000 --- a/wallets/webln/index.js +++ /dev/null @@ -1,16 +0,0 @@ -export const name = 'webln' -export const walletType = 'WEBLN' -export const walletField = 'walletWebLN' - -export const validate = ({ enabled }) => { - if (enabled && typeof window !== 'undefined' && !window?.webln) { - throw new Error('no WebLN provider found') - } -} - -export const fields = [] - -export const card = { - title: 'WebLN', - subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments' -} diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index 6d987e76..a87c7a9d 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -42,7 +42,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { if (pendingOrFailed.exists) return - for await (const { invoice, wallet, logger } of createUserInvoice(id, { + for await (const { invoice, protocol, logger } of createUserInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 @@ -50,10 +50,10 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { try { return await createWithdrawal(null, { invoice, maxFee: msatsToSats(maxFeeMsats) }, - { me: { id }, models, lnd, wallet, logger }) + { me: { id }, models, lnd, protocol, logger }) } catch (err) { console.error('failed to create autowithdrawal:', err) - logger?.error('incoming payment failed: ' + err.message, { bolt11: invoice }) + logger?.warn('incoming payment failed: ' + err.message, { bolt11: invoice }) } } diff --git a/worker/index.js b/worker/index.js index 03b1a3f4..20848e23 100644 --- a/worker/index.js +++ b/worker/index.js @@ -4,8 +4,7 @@ import PgBoss from 'pg-boss' import createPrisma from '@/lib/create-prisma' import { checkInvoice, checkPendingDeposits, checkPendingWithdrawals, - checkWithdrawal, checkWallet, - finalizeHodlInvoice, subscribeToWallet + checkWithdrawal, finalizeHodlInvoice, subscribeToWallet } from './wallet' import { repin } from './repin' import { trust } from './trust' @@ -144,7 +143,6 @@ async function work () { await boss.work('reminder', jobWrapper(remindUser)) await boss.work('thisDay', jobWrapper(thisDay)) await boss.work('socialPoster', jobWrapper(postToSocial)) - await boss.work('checkWallet', jobWrapper(checkWallet)) console.log('working jobs') } diff --git a/worker/paidAction.js b/worker/paidAction.js index d628e3fd..d24e7a27 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -1,6 +1,6 @@ import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd' import { paidActions } from '@/api/paidAction' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server/logger' import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' import { datePivot } from '@/lib/time' @@ -10,7 +10,7 @@ import { getInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' -import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' +import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/server/wrap' // aggressive finalization retry options const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } @@ -44,7 +44,11 @@ async function transitionInvoice (jobName, include: { invoice: true, withdrawl: true, - wallet: true + protocol: { + include: { + wallet: true + } + } } } } @@ -234,8 +238,8 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode msatsPaying: BigInt(invoice.mtokens), msatsFeePaying: maxFeeMsats, autoWithdraw: true, - walletId: invoiceForward.walletId, - userId: invoiceForward.wallet.userId + protocolId: invoiceForward.protocolId, + userId: invoiceForward.protocol.wallet.userId } } } @@ -309,14 +313,15 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a }, { models, lnd, boss }) if (transitionedInvoice) { - const withdrawal = transitionedInvoice.invoiceForward.withdrawl - - const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) - logger.ok( - `↙ payment received: ${formatSats(msatsToSats(Number(withdrawal.msatsPaid)))}`, { - invoiceId: transitionedInvoice.id, - withdrawalId: withdrawal.id - }) + const { withdrawl, protocol } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id, + withdrawalId: withdrawl.id + }) + logger.ok(`↙ payment received: ${formatSats(msatsToSats(Number(withdrawl.msatsPaid)))}`) } return transitionedInvoice @@ -364,12 +369,15 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: }, { models, lnd, boss }) if (transitionedInvoice) { - const fwd = transitionedInvoice.invoiceForward - const logger = walletLogger({ wallet: fwd.wallet, models }) - logger.warn( - `incoming payment failed: ${message}`, { - withdrawalId: fwd.withdrawl.id - }) + const { withdrawl, protocol } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id, + withdrawalId: withdrawl.id + }) + logger.warn(`incoming payment failed: ${message}`) } return transitionedInvoice @@ -430,14 +438,15 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model if (transitionedInvoice) { if (transitionedInvoice.invoiceForward) { - const { wallet, bolt11 } = transitionedInvoice.invoiceForward - const logger = walletLogger({ wallet, models }) + const { protocol, bolt11 } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id + }) const decoded = await parsePaymentRequest({ request: bolt11 }) - logger.info( - `invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { - bolt11, - invoiceId: transitionedInvoice.id - }) + logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`) } } diff --git a/worker/payingAction.js b/worker/payingAction.js index 4d868f1a..c35b8450 100644 --- a/worker/payingAction.js +++ b/worker/payingAction.js @@ -1,5 +1,5 @@ import { getPaymentFailureStatus, getPaymentOrNotSent } from '@/api/lnd' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server/logger' import { formatMsats, formatSats, msatsToSats, toPositiveBigInt } from '@/lib/format' import { datePivot } from '@/lib/time' import { notifyWithdrawal } from '@/lib/webPush' @@ -28,7 +28,7 @@ async function transitionWithdrawal (jobName, // grab optimistic concurrency lock and the withdrawal dbWithdrawal = await tx.withdrawl.update({ include: { - wallet: true + protocol: true }, where: { id: withdrawalId, @@ -49,7 +49,7 @@ async function transitionWithdrawal (jobName, if (data) { return await tx.withdrawl.update({ include: { - wallet: true + protocol: true }, where: { id: dbWithdrawal.id }, data @@ -123,11 +123,15 @@ export async function payingActionConfirmed ({ data: args, models, lnd, boss }) if (transitionedWithdrawal) { await notifyWithdrawal(transitionedWithdrawal) - const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet }) - logger?.ok( - `↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`, { - withdrawalId: transitionedWithdrawal.id - }) + const { protocol, userId } = transitionedWithdrawal + + const logger = walletLogger({ + models, + protocol, + userId, + withdrawalId: transitionedWithdrawal.id + }) + logger?.ok(`↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`) } } diff --git a/worker/wallet.js b/worker/wallet.js index ab504d08..22f537ed 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -12,8 +12,6 @@ import { paidActionCanceling } from './paidAction' import { payingActionConfirmed, payingActionFailed } from './payingAction' -import { canReceive, getWalletByType } from '@/wallets/common' -import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' export async function subscribeToWallet (args) { await subscribeToDeposits(args) @@ -212,7 +210,6 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo ] }, include: { - wallet: true, invoiceForward: { include: { invoice: true @@ -286,73 +283,3 @@ export async function checkPendingWithdrawals (args) { } } } - -export async function checkWallet ({ data: { userId }, models }) { - const pushNotifications = [] - - await models.$transaction(async tx => { - const wallets = await tx.wallet.findMany({ - where: { - userId, - enabled: true - }, - include: { - vaultEntries: true - } - }) - - const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) - - const newHasRecvWallet = wallets.some(({ type, wallet }) => canReceive({ def: getWalletByType(type), config: wallet })) - const newHasSendWallet = wallets.some(({ vaultEntries }) => vaultEntries.length > 0) - - await tx.user.update({ - where: { id: userId }, - data: { - hasRecvWallet: newHasRecvWallet, - hasSendWallet: newHasSendWallet - } - }) - - const startStreak = async (type) => { - const streak = await tx.streak.create({ - data: { userId, type, startedAt: new Date() } - }) - return streak.id - } - - const endStreak = async (type) => { - const [streak] = await tx.$queryRaw` - UPDATE "Streak" - SET "endedAt" = now(), updated_at = now() - WHERE "userId" = ${userId} - AND "type" = ${type}::"StreakType" - AND "endedAt" IS NULL - RETURNING "id" - ` - return streak?.id - } - - if (!oldHasRecvWallet && newHasRecvWallet) { - const streakId = await startStreak('HORSE') - if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'HORSE', id: streakId })) - } - if (!oldHasSendWallet && newHasSendWallet) { - const streakId = await startStreak('GUN') - if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'GUN', id: streakId })) - } - - if (oldHasRecvWallet && !newHasRecvWallet) { - const streakId = await endStreak('HORSE') - if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'HORSE', id: streakId })) - } - if (oldHasSendWallet && !newHasSendWallet) { - const streakId = await endStreak('GUN') - if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'GUN', id: streakId })) - } - }) - - // run all push notifications at the end to make sure we don't - // accidentally send duplicate push notifications because of a job retry - await Promise.all(pushNotifications.map(notify => notify())).catch(console.error) -}