diff --git a/.env.development b/.env.development index a9242b5d5..419252140 100644 --- a/.env.development +++ b/.env.development @@ -29,8 +29,8 @@ SLACK_BOT_TOKEN= SLACK_CHANNEL_ID= # lnurl ... you'll need a tunnel to localhost:3000 for these -LNAUTH_URL= -LNWITH_URL= +LNAUTH_URL=http://localhost:3000/api/lnauth +LNWITH_URL=http://localhost:3000/api/lnwith ######################################## # SNDEV STUFF WE PRESET # @@ -126,27 +126,42 @@ RPC_PORT=18443 P2P_PORT=18444 ZMQ_BLOCK_PORT=28334 ZMQ_TX_PORT=28335 +ZMQ_HASHBLOCK_PORT=29000 -# sn lnd container stuff -LND_REST_PORT=8080 -LND_GRPC_PORT=10009 -LND_P2P_PORT=9735 +# sn_lnd container stuff +SN_LND_REST_PORT=8080 +SN_LND_GRPC_PORT=10009 +SN_LND_P2P_PORT=9735 # docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused -LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl -LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490 - -# stacker lnd container stuff -STACKER_LND_REST_PORT=8081 -STACKER_LND_GRPC_PORT=10010 +SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl +SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490 +# sn_lndk stuff +SN_LNDK_GRPC_PORT=10012 + +# lnd container stuff +LND_REST_PORT=8081 +LND_GRPC_PORT=10010 # docker exec -u lnd lnd lncli newaddress p2wkh --unused -STACKER_LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu -STACKER_LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35 +LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu +LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35 -# stacker cln container stuff -STACKER_CLN_REST_PORT=9092 +# cln container stuff +CLN_REST_PORT=9092 # docker exec -u clightning cln lightning-cli newaddr bech32 -STACKER_CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx -STACKER_CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90 +CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx +CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90 + +# sndev cli eclair getnewaddress +# sndev cli eclair getinfo +ECLAIR_ADDR="bcrt1qdus2yml69wsax3unz8pts9h979lc3s4tw0tpf6" +ECLAIR_PUBKEY="02268c74cc07837041131474881f97d497706b89a29f939555da6d094b65bd5af0" + +# router lnd container stuff +ROUTER_LND_REST_PORT=8082 +ROUTER_LND_GRPC_PORT=10011 +# docker exec -u lnd router_lnd lncli newaddress p2wkh --unused +ROUTER_LND_ADDR=bcrt1qfkmwfpwgn6wt0dd36s79x04swz8vleyafsdpdr +ROUTER_LND_PUBKEY=02750991fbf62e57631888bc469fae69c5e658bd1d245d8ab95ed883517caa33c3 LNCLI_NETWORK=regtest diff --git a/.gitignore b/.gitignore index ceb04b515..3f2c478fa 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,7 @@ docker-compose.*.yml scripts/nwc-keys.json # lnbits -docker/lnbits/data \ No newline at end of file +docker/lnbits/data + +# lndk +!docker/lndk/tls-*.pem \ No newline at end of file diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cc9ced4ae..7e65c4eb0 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -317,34 +317,39 @@ export async function retryPaidAction (actionType, args, incomingContext) { optimistic: actionOptimistic, me: await models.user.findUnique({ where: { id: parseInt(me.id) } }), cost: BigInt(msatsRequested), - actionId + actionId, + predecessorId: failedInvoice.id } let invoiceArgs const invoiceForward = await models.invoiceForward.findUnique({ - where: { invoiceId: failedInvoice.id }, + where: { + invoiceId: failedInvoice.id + }, include: { - wallet: true, - invoice: true, - withdrawl: true + wallet: true } }) - // TODO: receiver fallbacks - // use next receiver wallet if forward failed (we currently immediately fallback to SN) - const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED' - if (invoiceForward && !failedForward) { - const { userId } = invoiceForward.wallet - const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, 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 } - } else { - invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext) + + if (invoiceForward) { + // this is a wrapped invoice, we need to retry it with receiver fallbacks + try { + const { userId } = invoiceForward.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, { + 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 } + } catch (err) { + console.log('failed to retry wrapped invoice, falling back to SN:', err) + } } + invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext) + return await models.$transaction(async tx => { const context = { ...retryContext, tx, invoiceArgs } @@ -404,7 +409,7 @@ async function createSNInvoice (actionType, args, context) { } async function createDbInvoice (actionType, args, context) { - const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context + const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs const db = tx ?? models @@ -429,7 +434,8 @@ async function createDbInvoice (actionType, args, context) { actionOptimistic: optimistic, actionArgs: args, expiresAt, - actionId + actionId, + predecessorId } let invoice diff --git a/api/payingAction/index.js b/api/payingAction/index.js index 0d2765376..2ff7117a7 100644 --- a/api/payingAction/index.js +++ b/api/payingAction/index.js @@ -1,4 +1,4 @@ -import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format' import { Prisma } from '@prisma/client' import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service' @@ -44,7 +44,8 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId }, lnd, request: withdrawal.bolt11, max_fee: msatsToSats(withdrawal.msatsFeePaying), - pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM }).catch(console.error) return withdrawal diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 3092bdab9..2f0676a94 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -8,7 +8,8 @@ import { COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS, - BOOST_MULT + BOOST_MULT, + ITEM_EDIT_SECONDS } from '@/lib/constants' import { msatsToSats } from '@/lib/format' import { parse } from 'tldts' @@ -1350,8 +1351,9 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. throw new GqlInputError('item is deleted') } - // author can edit their own item (except anon) const meId = Number(me?.id ?? USER_ID.anon) + + // author can edit their own item (except anon) const authorEdit = !!me && Number(old.userId) === meId // admins can edit special items const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId) @@ -1360,9 +1362,9 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. if (old.invoice?.hash && hash && hmac) { hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) } - // ownership permission check - if (!authorEdit && !adminEdit && !hmacEdit) { + const ownerEdit = authorEdit || adminEdit || hmacEdit + if (!ownerEdit) { throw new GqlInputError('item does not belong to you') } @@ -1379,12 +1381,12 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. const user = await models.user.findUnique({ where: { id: meId } }) - // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes + // edits are only allowed for own items within 10 minutes + // but forever if an admin is editing an "admin item", it's their bio or a job const myBio = user.bioId === old.id - const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { minutes: 10 }) - - // timer permission check - if (!adminEdit && !myBio && !timer && !isJob(item)) { + const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS }) + const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(item) + if (!canEdit) { throw new GqlInputError('item can no longer be edited') } diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index 3dcfd2c25..a65ebc43e 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -157,7 +157,7 @@ export default { const [{ to, from }] = await models.$queryRaw` SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from, (date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to` - return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context }) + return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context }) }, total: async (parent, args, { models }) => { if (!parent.total) { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 33d15eb14..7b4c8ac3f 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -67,11 +67,12 @@ export async function topUsers (parent, { cursor, when, by, from, to, limit = LI case 'comments': column = 'ncomments'; break case 'referrals': column = 'referrals'; break case 'stacking': column = 'stacked'; break + case 'value': default: column = 'proportion'; break } const users = (await models.$queryRawUnsafe(` - SELECT * + SELECT * ${column === 'proportion' ? ', proportion' : ''} FROM (SELECT users.*, COALESCE(floor(sum(msats_spent)/1000), 0) as spent, diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b0f9f7798..5621d946b 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -8,7 +8,8 @@ import { SELECT, itemQueryWithMeta } from './item' import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' import { USER_ID, INVOICE_RETENTION_DAYS, - PAID_ACTION_PAYMENT_METHODS + PAID_ACTION_PAYMENT_METHODS, + WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import assertGofacYourself from './ofac' @@ -21,9 +22,10 @@ import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' -import { canReceive } from '@/wallets/common' +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:') @@ -63,9 +65,15 @@ function injectResolvers (resolvers) { return await upsertWallet({ wallet, + walletDef, testCreateInvoice: walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data }) - ? (data) => walletDef.testCreateInvoice(data, { logger, me, models }) + ? (data) => withTimeout( + walletDef.testCreateInvoice(data, { + logger, + signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) + }), + WALLET_CREATE_INVOICE_TIMEOUT_MS) : null }, { settings, @@ -551,7 +559,10 @@ const resolvers = { const logger = walletLogger({ wallet, models }) await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) - logger.info('wallet detached') + + if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) { + logger.info('details for receiving deleted') + } return true }, @@ -606,6 +617,15 @@ const resolvers = { satsReceived: i => msatsToSats(i.msatsReceived), satsRequested: i => msatsToSats(i.msatsRequested), // we never want to fetch the sensitive data full monty in nested resolvers + forwardStatus: async (invoice, args, { models }) => { + const forward = await models.invoiceForward.findUnique({ + where: { invoiceId: Number(invoice.id) }, + include: { + withdrawl: true + } + }) + return forward?.withdrawl?.status + }, forwardedSats: async (invoice, args, { models }) => { const msats = (await models.invoiceForward.findUnique({ where: { invoiceId: Number(invoice.id) }, @@ -750,7 +770,7 @@ export const walletLogger = ({ wallet, models }) => { } async function upsertWallet ( - { wallet, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { + { wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { if (!me) { throw new GqlAuthenticationError() } @@ -856,24 +876,26 @@ async function upsertWallet ( ) } - txs.push( - models.walletLog.createMany({ - data: { - userId: me.id, - wallet: wallet.type, - level: 'SUCCESS', - message: id ? 'wallet details updated' : 'wallet attached' - } - }), - models.walletLog.create({ - data: { - userId: me.id, - wallet: wallet.type, - level: enabled ? 'SUCCESS' : 'INFO', - message: enabled ? 'wallet enabled' : 'wallet disabled' - } - }) - ) + if (canReceive({ def: walletDef, config: walletData })) { + 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 diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 09cbf53dd..2e4f69665 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -61,6 +61,11 @@ export default gql` photoId: Int since: Int + """ + this is only returned when we sort stackers by value + """ + proportion: Float + optional: UserOptional! privates: UserPrivates diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 97f4f69e8..932b67bcd 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -129,6 +129,7 @@ const typeDefs = ` item: Item itemAct: ItemAct forwardedSats: Int + forwardStatus: String } type Withdrawl { diff --git a/awards.csv b/awards.csv index 9a4f38995..3d70902ab 100644 --- a/awards.csv +++ b/awards.csv @@ -152,10 +152,11 @@ Gudnessuche,issue,#1662,#1661,good-first-issue,,,,2k,everythingsatoshi@getalby.c aegroto,pr,#1589,#1586,easy,,,,100k,aegroto@blink.sv,2024-12-07 aegroto,issue,#1589,#1586,easy,,,,10k,aegroto@blink.sv,2024-12-07 aegroto,pr,#1619,#914,easy,,,,100k,aegroto@blink.sv,2024-12-07 -felipebueno,pr,#1620,,medium,,,1,225k,felipebueno@getalby.com,??? +felipebueno,pr,#1620,,medium,,,1,225k,felipebueno@getalby.com,2024-12-09 Soxasora,pr,#1647,#1645,easy,,,,100k,soxasora@blink.sv,2024-12-07 Soxasora,pr,#1667,#1568,easy,,,,100k,soxasora@blink.sv,2024-12-07 aegroto,pr,#1633,#1471,easy,,,1,90k,aegroto@blink.sv,2024-12-07 Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07 Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07 aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07 +sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10 diff --git a/capture/Dockerfile b/capture/Dockerfile index daf27e4de..5c745426f 100644 --- a/capture/Dockerfile +++ b/capture/Dockerfile @@ -11,7 +11,7 @@ RUN npm ci COPY . . -ADD http://ftp.de.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb +ADD https://deb.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb RUN dpkg -i fonts-noto-color-emoji.deb CMD [ "node", "index.js" ] -USER pptruser \ No newline at end of file +USER pptruser diff --git a/components/item-act.js b/components/item-act.js index 372895676..459fff7b7 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -232,9 +232,15 @@ 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 [act] = usePaidMutation(query, { - waitFor: inv => inv?.satsReceived > 0, + 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 + ? inv?.actionState === 'PAID' + : inv?.satsReceived > 0, ...options, update: (cache, { data }) => { const response = getPaidActionResult(data) diff --git a/components/lightning-auth.js b/components/lightning-auth.js index 62b2f061d..8f7118f4b 100644 --- a/components/lightning-auth.js +++ b/components/lightning-auth.js @@ -36,7 +36,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) { await window.webln.enable() await window.webln.lnurl(encodedUrl) } - effect() + effect().catch(console.error) }, [encodedUrl]) // output pubkey and k1 diff --git a/components/nav/index.js b/components/nav/index.js index 9413bbd65..beacbd6fc 100644 --- a/components/nav/index.js +++ b/components/nav/index.js @@ -2,6 +2,7 @@ import { useRouter } from 'next/router' import DesktopHeader from './desktop/header' import MobileHeader from './mobile/header' import StickyBar from './sticky-bar' +import { PriceCarouselProvider } from './price-carousel' export default function Navigation ({ sub }) { const router = useRouter() @@ -16,10 +17,10 @@ export default function Navigation ({ sub }) { } return ( - <> + - + ) } diff --git a/components/nav/price-carousel.js b/components/nav/price-carousel.js new file mode 100644 index 000000000..0b09c6721 --- /dev/null +++ b/components/nav/price-carousel.js @@ -0,0 +1,46 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' + +const STORAGE_KEY = 'asSats' +const DEFAULT_SELECTION = 'fiat' + +const carousel = [ + 'fiat', + 'yep', + '1btc', + 'blockHeight', + 'chainFee', + 'halving' +] + +export const PriceCarouselContext = createContext({ + selection: undefined, + handleClick: () => {} +}) + +export function PriceCarouselProvider ({ children }) { + const [selection, setSelection] = useState(undefined) + const [pos, setPos] = useState(0) + + useEffect(() => { + const selection = window.localStorage.getItem(STORAGE_KEY) ?? DEFAULT_SELECTION + setSelection(selection) + setPos(carousel.findIndex((item) => item === selection)) + }, []) + + const handleClick = useCallback(() => { + const nextPos = (pos + 1) % carousel.length + window.localStorage.setItem(STORAGE_KEY, carousel[nextPos]) + setSelection(carousel[nextPos]) + setPos(nextPos) + }, [pos]) + + return ( + + {children} + + ) +} + +export function usePriceCarousel () { + return useContext(PriceCarouselContext) +} diff --git a/components/nav/static.js b/components/nav/static.js index 9f453df08..707fccc62 100644 --- a/components/nav/static.js +++ b/components/nav/static.js @@ -1,19 +1,22 @@ import { Container, Nav, Navbar } from 'react-bootstrap' import styles from '../header.module.css' import { BackOrBrand, NavPrice, SearchItem } from './common' +import { PriceCarouselProvider } from './price-carousel' export default function StaticHeader () { return ( - - - - - + + + + + + + ) } diff --git a/components/nostr-auth.js b/components/nostr-auth.js index 862326dab..a1bcd1869 100644 --- a/components/nostr-auth.js +++ b/components/nostr-auth.js @@ -1,71 +1,78 @@ -import { useEffect, useState } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { gql, useMutation } from '@apollo/client' import { signIn } from 'next-auth/react' -import Container from 'react-bootstrap/Container' import Col from 'react-bootstrap/Col' import Row from 'react-bootstrap/Row' import { useRouter } from 'next/router' import AccordianItem from './accordian-item' import BackIcon from '@/svgs/arrow-left-line.svg' +import Nostr from '@/lib/nostr' +import { NDKNip46Signer } from '@nostr-dev-kit/ndk' +import { useToast } from '@/components/toast' +import { Button, Container } from 'react-bootstrap' +import { Form, Input, SubmitButton } from '@/components/form' +import Moon from '@/svgs/moon-fill.svg' import styles from './lightning-auth.module.css' -import { callWithTimeout } from '@/lib/time' -function ExtensionError ({ message, details }) { - return ( - <> -

error: {message}

-
{details}
- - ) +const sanitizeURL = (s) => { + try { + const url = new URL(s) + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('invalid protocol') + return url.href + } catch (e) { + return null + } } -function NostrExplainer ({ text }) { +function NostrError ({ message }) { return ( <> - - - - - -
    -
  • - Alby
    - available for: chrome, firefox, and safari -
  • -
  • - Flamingo
    - available for: chrome -
  • -
  • - nos2x
    - available for: chrome -
  • -
  • - nos2x-fox
    - available for: firefox -
  • -
  • - horse
    - available for: chrome
    - supports hardware signing -
  • -
- -
- - } - /> -
+

error

+
{message}
) } export function NostrAuth ({ text, callbackUrl, multiAuth }) { - const [createAuth, { data, error }] = useMutation(gql` + const [status, setStatus] = useState({ + msg: '', + error: false, + loading: false, + title: undefined, + button: undefined + }) + + const [suggestion, setSuggestion] = useState(null) + const suggestionTimeout = useRef(null) + const toaster = useToast() + + const challengeResolver = useCallback(async (challenge) => { + const challengeUrl = sanitizeURL(challenge) + if (challengeUrl) { + setStatus({ + title: 'Waiting for confirmation', + msg: 'Please confirm this action on your remote signer', + error: false, + loading: true, + button: { + label: 'open signer', + action: () => { + window.open(challengeUrl, '_blank') + } + } + }) + } else { + setStatus({ + title: 'Waiting for confirmation', + msg: challenge, + error: false, + loading: true + }) + } + }, []) + + // create auth challenge + const [createAuth] = useMutation(gql` mutation createAuth { createAuth { k1 @@ -74,83 +81,253 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) { // don't cache this mutation fetchPolicy: 'no-cache' }) - const [hasExtension, setHasExtension] = useState(undefined) - const [extensionError, setExtensionError] = useState(null) - useEffect(() => { - createAuth() - setHasExtension(!!window.nostr) + // print an error message + const setError = useCallback((e) => { + console.error(e) + toaster.danger(e.message || e.toString()) + setStatus({ + msg: e.message || e.toString(), + error: true, + loading: false + }) }, []) - const k1 = data?.createAuth.k1 + const clearSuggestionTimer = () => { + if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current) + } + + const setSuggestionWithTimer = (msg) => { + clearSuggestionTimer() + suggestionTimeout.current = setTimeout(() => { + setSuggestion(msg) + }, 10_000) + } useEffect(() => { - if (!k1 || !hasExtension) return - - console.info('nostr extension detected') - - let mounted = true; - (async function () { - try { - // have them sign a message with the challenge - let event - try { - event = await callWithTimeout(() => window.nostr.signEvent({ - kind: 22242, - created_at: Math.floor(Date.now() / 1000), - tags: [['challenge', k1]], - content: 'Stacker News Authentication' - }), 5000) - if (!event) throw new Error('extension returned empty event') - } catch (e) { - if (e.message === 'window.nostr call already executing' || !mounted) return - setExtensionError({ message: 'nostr extension failed to sign event', details: e.message }) - return - } + return () => { + clearSuggestionTimer() + } + }, []) - // sign them in - try { - await signIn('nostr', { - event: JSON.stringify(event), - callbackUrl, - multiAuth - }) - } catch (e) { - throw new Error('authorization failed', e) - } - } catch (e) { - if (!mounted) return - console.log('nostr auth error', e) - setExtensionError({ message: `${text} failed`, details: e.message }) + // authorize user + const auth = useCallback(async (nip46token) => { + setStatus({ + msg: 'Waiting for authorization', + error: false, + loading: true + }) + try { + const { data, error } = await createAuth() + if (error) throw error + + const k1 = data?.createAuth.k1 + if (!k1) throw new Error('Error generating challenge') // should never happen + + const useExtension = !nip46token + const signer = Nostr.getSigner({ nip46token, supportNip07: useExtension }) + if (!signer && useExtension) throw new Error('No extension found') + + if (signer instanceof NDKNip46Signer) { + signer.once('authUrl', challengeResolver) } - })() - return () => { mounted = false } - }, [k1, hasExtension]) - if (error) return
error
+ setSuggestionWithTimer('Having trouble? Make sure you used a fresh token or valid NIP-05 address') + await signer.blockUntilReady() + clearSuggestionTimer() + + setStatus({ + msg: 'Signing in', + error: false, + loading: true + }) + + const signedEvent = await Nostr.sign({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['challenge', k1], + ['u', process.env.NEXT_PUBLIC_URL], + ['method', 'GET'] + ], + content: 'Stacker News Authentication' + }, { signer }) + + await signIn('nostr', { + event: JSON.stringify(signedEvent), + callbackUrl, + multiAuth + }) + } catch (e) { + setError(e) + } finally { + clearSuggestionTimer() + } + }, []) return ( <> - {hasExtension === false && } - {extensionError && } - {hasExtension && !extensionError && - <> -

nostr extension found

-
authorize event signature in extension
- } + {status.error && } + {status.loading + ? ( + <> +
+ + {status.msg} +
+ {status.button && ( + + )} + {suggestion && ( +
{suggestion}
+ )} + + ) + : ( + <> +
{ + if (!values.token) { + setError(new Error('Token or NIP-05 address is required')) + } else { + auth(values.token) + } + }} + > + +
+ + {text || 'Login'} with token or NIP-05 + +
+
+
or
+ + + )} ) } -export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) { +function NostrExplainer ({ text, children }) { const router = useRouter() return (
router.back()}>
-

{text || 'Login'} with Nostr

- +

+ {text || 'Login'} with Nostr +

+ + + + + +
    +
  • + Nsec.app +
      +
    • available for: chrome, firefox, and safari
    • +
    +
  • +
  • + nsecBunker +
      +
    • available as: SaaS or self-hosted
    • +
    +
  • +
+ +
+ + } + /> + + + +
    +
  • + Alby +
      +
    • available for: chrome, firefox, and safari
    • +
    +
  • +
  • + Flamingo +
      +
    • available for: chrome
    • +
    +
  • +
  • + nos2x +
      +
    • available for: chrome
    • +
    +
  • +
  • + nos2x-fox +
      +
    • available for: firefox
    • +
    +
  • +
  • + horse +
      +
    • available for: chrome
    • +
    • supports hardware signing
    • +
    +
  • +
+ +
+ + } + /> + + + {children} + +
) } + +export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) { + return ( + + + + ) +} diff --git a/components/notifications.js b/components/notifications.js index 491643278..a4937c3bf 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -501,13 +501,23 @@ function Invoicification ({ n: { invoice, sortTime } }) { } function WithdrawlPaid ({ n }) { - let actionString = n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account' + let amount = n.earnedSats + n.withdrawl.satsFeePaid + let actionString = 'withdrawn from your account' + + if (n.withdrawl.autoWithdraw) { + actionString = 'sent to your attached wallet' + } + if (n.withdrawl.forwardedActionType === 'ZAP') { + // don't expose receivers to routing fees they aren't paying + amount = n.earnedSats actionString = 'zapped directly to your attached wallet' } + return (
- {numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} + + {numWithUnits(amount, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} {actionString} {timeSince(new Date(n.sortTime))} {(n.withdrawl.forwardedActionType === 'ZAP' && p2p) || diff --git a/components/price.js b/components/price.js index b104b5df8..6961dee67 100644 --- a/components/price.js +++ b/components/price.js @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react' +import React, { useContext, useMemo } from 'react' import { useQuery } from '@apollo/client' import { fixedDecimal } from '@/lib/format' import { useMe } from './me' @@ -8,6 +8,7 @@ import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' import { useBlockHeight } from './block-height' import { useChainFee } from './chain-fee' import { CompactLongCountdown } from './countdown' +import { usePriceCarousel } from './nav/price-carousel' export const PriceContext = React.createContext({ price: null, @@ -43,43 +44,16 @@ export function PriceProvider ({ price, children }) { ) } -const STORAGE_KEY = 'asSats' -const DEFAULT_SELECTION = 'fiat' - -const carousel = [ - 'fiat', - 'yep', - '1btc', - 'blockHeight', - 'chainFee', - 'halving' -] - export default function Price ({ className }) { - const [asSats, setAsSats] = useState(undefined) - const [pos, setPos] = useState(0) - - useEffect(() => { - const selection = window.localStorage.getItem(STORAGE_KEY) ?? DEFAULT_SELECTION - setAsSats(selection) - setPos(carousel.findIndex((item) => item === selection)) - }, []) + const [selection, handleClick] = usePriceCarousel() const { price, fiatSymbol } = usePrice() const { height: blockHeight, halving } = useBlockHeight() const { fee: chainFee } = useChainFee() - const handleClick = () => { - const nextPos = (pos + 1) % carousel.length - - window.localStorage.setItem(STORAGE_KEY, carousel[nextPos]) - setAsSats(carousel[nextPos]) - setPos(nextPos) - } - const compClassName = (className || '') + ' text-reset pointer' - if (asSats === 'yep') { + if (selection === 'yep') { if (!price || price < 0) return null return (
@@ -88,7 +62,7 @@ export default function Price ({ className }) { ) } - if (asSats === '1btc') { + if (selection === '1btc') { return (
1sat=1sat @@ -96,7 +70,7 @@ export default function Price ({ className }) { ) } - if (asSats === 'blockHeight') { + if (selection === 'blockHeight') { if (blockHeight <= 0) return null return (
@@ -105,7 +79,7 @@ export default function Price ({ className }) { ) } - if (asSats === 'halving') { + if (selection === 'halving') { if (!halving) return null return (
@@ -114,7 +88,7 @@ export default function Price ({ className }) { ) } - if (asSats === 'chainFee') { + if (selection === 'chainFee') { if (chainFee <= 0) return null return (
@@ -123,7 +97,7 @@ export default function Price ({ className }) { ) } - if (asSats === 'fiat') { + if (selection === 'fiat') { if (!price || price < 0) return null return (
diff --git a/components/toast.js b/components/toast.js index e6b985454..3a1a28eb3 100644 --- a/components/toast.js +++ b/components/toast.js @@ -118,7 +118,7 @@ export const ToastProvider = ({ children }) => { return ( - + {visibleToasts.map(toast => { const textStyle = toast.variant === 'warning' ? 'text-dark' : '' const onClose = () => { diff --git a/components/use-can-edit.js b/components/use-can-edit.js index bc31f17ac..b97596e4e 100644 --- a/components/use-can-edit.js +++ b/components/use-can-edit.js @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react' import { datePivot } from '@/lib/time' import { useMe } from '@/components/me' -import { USER_ID } from '@/lib/constants' +import { ITEM_EDIT_SECONDS, USER_ID } from '@/lib/constants' export default function useCanEdit (item) { - const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 }) + const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS }) const { me } = useMe() // deleted items can never be edited and every item has a 10 minute edit window diff --git a/components/use-crossposter.js b/components/use-crossposter.js index 8798009be..e4ee529d4 100644 --- a/components/use-crossposter.js +++ b/components/use-crossposter.js @@ -1,8 +1,7 @@ import { useCallback } from 'react' import { useToast } from './toast' import { Button } from 'react-bootstrap' -import { DEFAULT_CROSSPOSTING_RELAYS, crosspost } from '@/lib/nostr' -import { callWithTimeout } from '@/lib/time' +import Nostr, { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client' import { SETTINGS } from '@/fragments/users' import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items' @@ -204,7 +203,7 @@ export default function useCrossposter () { do { try { - const result = await crosspost(event, failedRelays || relays) + const result = await Nostr.crosspost(event, { relays: failedRelays || relays }) if (result.error) { failedRelays = [] @@ -239,13 +238,6 @@ export default function useCrossposter () { } const handleCrosspost = useCallback(async (itemId) => { - try { - const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 10000) - if (!pubkey) throw new Error('failed to get pubkey') - } catch (e) { - throw new Error(`Nostr extension error: ${e.message}`) - } - let noteId try { diff --git a/components/use-invoice.js b/components/use-invoice.js index 977aa2d77..cfdb2c6dd 100644 --- a/components/use-invoice.js +++ b/components/use-invoice.js @@ -1,8 +1,8 @@ import { useApolloClient, useMutation } from '@apollo/client' import { useCallback } from 'react' +import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors' import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet' -import { InvoiceExpiredError, InvoiceCanceledError } from '@/wallets/errors' export default function useInvoice () { const client = useApolloClient() @@ -16,20 +16,21 @@ export default function useInvoice () { throw error } - const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice + const { cancelled, cancelledAt, actionError, expiresAt, forwardStatus } = data.invoice const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt) if (expired) { throw new InvoiceExpiredError(data.invoice) } - if (cancelled || actionError) { - throw new InvoiceCanceledError(data.invoice, actionError) + const failedForward = forwardStatus && forwardStatus !== 'CONFIRMED' + if (failedForward) { + throw new WalletReceiverError(data.invoice) } - // write to cache if paid - if (actionState === 'PAID') { - client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } }) + const failed = cancelled || actionError + if (failed) { + throw new InvoiceCanceledError(data.invoice, actionError) } return { invoice: data.invoice, check: that(data.invoice) } diff --git a/components/user-list.js b/components/user-list.js index 40f77286f..90be34f8e 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -107,13 +107,13 @@ export function User ({ user, rank, statComps, className = 'mb-2', Embellish, ny
{statComps.map((Comp, i) => )}
} - {Embellish && } + {Embellish && } ) } -function UserHidden ({ rank, Embellish }) { +function UserHidden ({ rank, user, Embellish }) { return ( <> {rank @@ -133,7 +133,7 @@ function UserHidden ({ rank, Embellish }) {
stacker is in hiding
- {Embellish && } + {Embellish && }
@@ -148,7 +148,7 @@ export function ListUsers ({ users, rank, statComps = DEFAULT_STAT_COMPONENTS, E {users.map((user, i) => ( user ? - : + : ))}
) diff --git a/components/wallet-logger.js b/components/wallet-logger.js index a7ef8f206..2344b572d 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -133,7 +133,7 @@ export function useWalletLogManager (setLogs) { `, { onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)) + setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== walletTag(getWalletByType(walletType)) : false)) } } ) @@ -259,12 +259,12 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { if (hasMore) { setLoading(true) const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def) - _setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data])) + setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data])) setHasMore(result.hasMore) setPage(prevPage => prevPage + 1) setLoading(false) } - }, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore]) + }, [setLogs, loadLogsPage, page, logsPerPage, wallet?.def, hasMore]) const loadNew = useCallback(async () => { const latestTs = latestTimestamp.current diff --git a/docker-compose.yml b/docker-compose.yml index 17ae74881..40406609c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -241,12 +241,15 @@ services: - '-debug=1' - '-zmqpubrawblock=tcp://0.0.0.0:${ZMQ_BLOCK_PORT}' - '-zmqpubrawtx=tcp://0.0.0.0:${ZMQ_TX_PORT}' + - '-zmqpubhashblock=tcp://bitcoin:${ZMQ_HASHBLOCK_PORT}' - '-txindex=1' - '-dnsseed=0' - '-upnp=0' - '-rpcbind=0.0.0.0' - '-rpcallowip=0.0.0.0/0' + - '-whitelist=0.0.0.0/0' - '-rpcport=${RPC_PORT}' + - '-deprecatedrpc=signrawtransaction' - '-rest' - '-listen=1' - '-listenonion=0' @@ -262,6 +265,8 @@ services: volumes: - bitcoin:/home/bitcoin/.bitcoin labels: + CLI: "bitcoin-cli" + CLI_ARGS: "-chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS}" ofelia.enabled: "true" ofelia.job-exec.minecron.schedule: "@every 1m" ofelia.job-exec.minecron.command: > @@ -270,12 +275,14 @@ services: command bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} "$$@" } blockcount=$$(bitcoin-cli getblockcount 2>/dev/null) - nodes=(${LND_ADDR} ${STACKER_LND_ADDR} ${STACKER_CLN_ADDR}) + + nodes=(${SN_LND_ADDR} ${LND_ADDR} ${CLN_ADDR} ${ROUTER_LND_ADDR} ${ECLAIR_ADDR}) + if (( blockcount <= 0 )); then echo "Creating wallet and address..." bitcoin-cli createwallet "" nodes+=($$(bitcoin-cli getnewaddress)) - echo "Mining 100 blocks to sn_lnd, lnd, cln..." + echo "Mining 100 blocks to sn_lnd, lnd, cln, eclair..." for addr in "$${nodes[@]}"; do bitcoin-cli generatetoaddress 100 $$addr echo "Mining 100 blocks to a random address..." @@ -341,11 +348,15 @@ services: - '--allow-circular-route' - '--bitcoin.defaultchanconfs=1' - '--maxpendingchannels=10' + - '--gossip.sub-batch-delay=1s' + - '--protocol.custom-message=513' + - '--protocol.custom-nodeann=39' + - '--protocol.custom-init=39' expose: - "9735" ports: - - "${LND_REST_PORT}:8080" - - "${LND_GRPC_PORT}:10009" + - "${SN_LND_REST_PORT}:8080" + - "${SN_LND_GRPC_PORT}:10009" volumes: - sn_lnd:/home/lnd/.lnd labels: @@ -358,11 +369,39 @@ services: if [ $$(lncli getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then exit 0 else - lncli openchannel --node_key=$STACKER_LND_PUBKEY --connect lnd:9735 --sat_per_vbyte 1 \\ + lncli openchannel --node_key=$ROUTER_LND_PUBKEY --connect router_lnd:9735 --sat_per_vbyte 1 \\ --min_confs 0 --local_amt=1000000000 --push_amt=500000000 fi " cpu_shares: "${CPU_SHARES_MODERATE}" + sn_lndk: + platform: linux/x86_64 + build: + context: ./docker/lndk + container_name: sn_lndk + restart: unless-stopped + profiles: + - wallets + depends_on: + sn_lnd: + condition: service_healthy + restart: true + env_file: *env_file + command: + - 'lndk' + - '--grpc-host=0.0.0.0' + - '--address=https://sn_lnd:10009' + - '--cert-path=/home/lnd/.lnd/tls.cert' + - '--tls-ip=sn_lndk' + - '--macaroon-path=/home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon' + ports: + - "${SN_LNDK_GRPC_PORT}:7000" + volumes: + - sn_lnd:/home/lnd/.lnd + labels: + CLI: "lndk-cli --macaroon-path=/home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon" + CLI_USER: "lndk" + cpu_shares: "${CPU_SHARES_MODERATE}" lnd: build: context: ./docker/lnd @@ -412,8 +451,8 @@ services: - "9735" - "10009" ports: - - "${STACKER_LND_REST_PORT}:8080" - - "${STACKER_LND_GRPC_PORT}:10009" + - "${LND_REST_PORT}:8080" + - "${LND_GRPC_PORT}:10009" volumes: - lnd:/home/lnd/.lnd - tordata:/home/lnd/.tor @@ -429,7 +468,7 @@ services: if [ $$(lncli getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then exit 0 else - lncli openchannel --node_key=$LND_PUBKEY --connect sn_lnd:9735 --sat_per_vbyte 1 \\ + lncli openchannel --node_key=$ROUTER_LND_PUBKEY --connect router_lnd:9735 --sat_per_vbyte 1 \\ --min_confs 0 --local_amt=1000000000 --push_amt=500000000 fi " @@ -477,7 +516,7 @@ services: container_name: cln restart: unless-stopped profiles: - - payments + - wallets healthcheck: <<: *healthcheck test: ["CMD-SHELL", "su clightning -c 'lightning-cli --network=regtest getinfo'"] @@ -489,6 +528,8 @@ services: env_file: *env_file command: - 'lightningd' + - '--addr=0.0.0.0:9735' + - '--announce-addr=cln:9735' - '--network=regtest' - '--alias=cln' - '--bitcoin-rpcconnect=bitcoin' @@ -497,11 +538,10 @@ services: - '--large-channels' - '--rest-port=3010' - '--rest-host=0.0.0.0' - - '--log-file=/home/clightning/.lightning/debug.log' expose: - "9735" ports: - - "${STACKER_CLN_REST_PORT}:3010" + - "${CLN_REST_PORT}:3010" volumes: - cln:/home/clightning/.lightning - tordata:/home/clightning/.tor @@ -517,12 +557,120 @@ services: if [ $$(lightning-cli --regtest getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then exit 0 else - lightning-cli --regtest connect $LND_PUBKEY@sn_lnd:9735 - lightning-cli --regtest fundchannel id=$LND_PUBKEY feerate=1000perkb \\ + lightning-cli --regtest connect $ROUTER_LND_PUBKEY@router_lnd:9735 + lightning-cli --regtest fundchannel id=$ROUTER_LND_PUBKEY feerate=1000perkb \\ amount=1000000000 push_msat=500000000000 minconf=0 fi " cpu_shares: "${CPU_SHARES_MODERATE}" + eclair: + build: + context: ./docker/eclair + args: + - LN_NODE_FOR=stacker + container_name: eclair + profiles: + - wallets + restart: unless-stopped + depends_on: + <<: *depends_on_bitcoin + environment: + <<: *env_file + JAVA_OPTS: + -Declair.printToConsole + -Dakka.loglevel=DEBUG + -Declair.server.port=9735 + -Declair.server.public-ips.0=eclair + -Declair.api.binding-ip=0.0.0.0 + -Declair.api.enabled=true + -Declair.api.port=8080 + -Declair.api.password=pass + -Declair.node-alias=eclair + -Declair.chain=regtest + -Declair.bitcoind.host=bitcoin + -Declair.bitcoind.rpcport=${RPC_PORT} + -Declair.bitcoind.rpcuser=${RPC_USER} + -Declair.bitcoind.rpcpassword=${RPC_PASS} + -Declair.bitcoind.zmqblock=tcp://bitcoin:${ZMQ_HASHBLOCK_PORT} + -Declair.bitcoind.zmqtx=tcp://bitcoin:${ZMQ_TX_PORT} + -Declair.bitcoind.batch-watcher-requests=false + -Declair.features.option_onion_messages=optional + -Declair.features.option_route_blinding=optional + -Declair.features.keysend=optional + -Declair.channel.accept-incoming-static-remote-key-channels=true + -Declair.tip-jar.description=bolt12 + -Declair.tip-jar.default-amount-msat=100000000 + -Declair.tip-jar.max-final-expiry-delta=1000 + volumes: + - eclair:/data + expose: + - "9735" + labels: + CLI: "eclair-cli" + CLI_USER: "root" + CLI_ARGS: "-p pass" + ofelia.enabled: "true" + ofelia.job-exec.eclair_channel_cron.schedule: "@every 1m" + ofelia.job-exec.eclair_channel_cron.command: > + bash -c " + if [ $$(eclair-cli -p pass channels | jq 'length') -ge 3 ]; then + exit 0 + else + eclair-cli -p pass connect --uri=$SN_LND_PUBKEY@sn_lnd:9735 + eclair-cli -p pass open --nodeId=$SN_LND_PUBKEY --fundingFeerateSatByte=1 --fundingSatoshis=1000000 --pushMsat=500000000 --announceChannel=true + fi + " + router_lnd: + build: + context: ./docker/lnd + args: + - LN_NODE_FOR=router + container_name: router_lnd + restart: unless-stopped + profiles: + - payments + healthcheck: + <<: *healthcheck + test: ["CMD-SHELL", "lncli", "getinfo"] + depends_on: *depends_on_bitcoin + env_file: *env_file + command: + - 'lnd' + - '--noseedbackup' + - '--trickledelay=5000' + - '--alias=router_lnd' + - '--externalip=router_lnd' + - '--tlsextradomain=router_lnd' + - '--tlsextradomain=host.docker.internal' + - '--listen=0.0.0.0:9735' + - '--rpclisten=0.0.0.0:10009' + - '--restlisten=0.0.0.0:8080' + - '--bitcoin.active' + - '--bitcoin.regtest' + - '--bitcoin.node=bitcoind' + - '--bitcoind.rpchost=bitcoin' + - '--bitcoind.rpcuser=${RPC_USER}' + - '--bitcoind.rpcpass=${RPC_PASS}' + - '--bitcoind.zmqpubrawblock=tcp://bitcoin:${ZMQ_BLOCK_PORT}' + - '--bitcoind.zmqpubrawtx=tcp://bitcoin:${ZMQ_TX_PORT}' + - '--protocol.wumbo-channels' + - '--bitcoin.basefee=1000' + - '--bitcoin.feerate=0' + - '--maxchansize=1000000000' + - '--allow-circular-route' + - '--bitcoin.defaultchanconfs=1' + - '--maxpendingchannels=10' + expose: + - "9735" + ports: + - "${ROUTER_LND_REST_PORT}:8080" + - "${ROUTER_LND_GRPC_PORT}:10009" + volumes: + - router_lnd:/home/lnd/.lnd + labels: + CLI: "lncli" + CLI_USER: "lnd" + cpu_shares: "${CPU_SHARES_MODERATE}" channdler: image: mcuadros/ofelia:latest container_name: channdler @@ -532,7 +680,6 @@ services: - bitcoin - sn_lnd - lnd - - cln restart: unless-stopped command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME} volumes: @@ -660,7 +807,9 @@ volumes: sn_lnd: lnd: cln: + router_lnd: s3: nwc_send: nwc_recv: tordata: + eclair: \ No newline at end of file diff --git a/docker/eclair/Dockerfile b/docker/eclair/Dockerfile new file mode 100644 index 000000000..16316e377 --- /dev/null +++ b/docker/eclair/Dockerfile @@ -0,0 +1,68 @@ +# based on https://github.com/LN-Zap/bolt12-playground +FROM acinq/eclair:0.11.0 + + +ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh "-Declair.datadir=${ECLAIR_DATADIR}" + +################# +# Builder image # +################# +FROM maven:3.8.6-openjdk-11-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# References for eclair +ARG ECLAIR_REF=b73a009a1d7d7ea3a158776cd233512b9a538550 +ARG ECLAIR_PLUGINS_REF=cdc26dda96774fdc3b54075df078587574891fb7 + +WORKDIR /usr/src/eclair +RUN git clone https://github.com/ACINQ/eclair.git . \ + && git reset --hard ${ECLAIR_REF} +RUN mvn install -pl eclair-node -am -DskipTests -Dgit.commit.id=notag -Dgit.commit.id.abbrev=notag + +WORKDIR /usr/src/eclair-plugins +RUN git clone https://github.com/ACINQ/eclair-plugins.git . \ + && git reset --hard ${ECLAIR_PLUGINS_REF} +WORKDIR /usr/src/eclair-plugins/bolt12-tip-jar +RUN mvn package -DskipTests + +# ############### +# # final image # +# ############### +FROM openjdk:11.0.16-jre-slim-bullseye +WORKDIR /opt + +# Add utils +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash jq curl unzip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# copy and install eclair-cli executable +COPY --from=builder /usr/src/eclair/eclair-core/eclair-cli . +RUN chmod +x eclair-cli && mv eclair-cli /sbin/eclair-cli + +# we only need the eclair-node.zip to run +COPY --from=builder /usr/src/eclair/eclair-node/target/eclair-node-*.zip ./eclair-node.zip +RUN unzip eclair-node.zip && mv eclair-node-* eclair-node && chmod +x eclair-node/bin/eclair-node.sh + +# copy and install bolt12-tip-jar plugin +COPY --from=builder /usr/src/eclair-plugins/bolt12-tip-jar/target/bolt12-tip-jar-0.10.1-SNAPSHOT.jar . + +ENV ECLAIR_DATADIR=/data +ENV JAVA_OPTS= + +RUN mkdir -p "$ECLAIR_DATADIR" +VOLUME [ "/data" ] + +ARG LN_NODE_FOR +ENV LN_NODE_FOR=$LN_NODE_FOR +COPY ["./$LN_NODE_FOR/*", "/data"] + +# ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh "-Declair.datadir=${ECLAIR_DATADIR}" +ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh bolt12-tip-jar-0.10.1-SNAPSHOT.jar "-Declair.datadir=${ECLAIR_DATADIR}" \ No newline at end of file diff --git a/docker/eclair/stacker/channel_seed.dat b/docker/eclair/stacker/channel_seed.dat new file mode 100644 index 000000000..7b5b33cea --- /dev/null +++ b/docker/eclair/stacker/channel_seed.dat @@ -0,0 +1 @@ +thC(ĀBqF`iBL)L \ No newline at end of file diff --git a/docker/eclair/stacker/node_seed.dat b/docker/eclair/stacker/node_seed.dat new file mode 100644 index 000000000..5d5afca01 --- /dev/null +++ b/docker/eclair/stacker/node_seed.dat @@ -0,0 +1 @@ +61>bgO嵿}k !sb \ No newline at end of file diff --git a/docker/eclair/stacker/regtest/audit.sqlite b/docker/eclair/stacker/regtest/audit.sqlite new file mode 100644 index 000000000..cc0c24676 Binary files /dev/null and b/docker/eclair/stacker/regtest/audit.sqlite differ diff --git a/docker/eclair/stacker/regtest/eclair.sqlite b/docker/eclair/stacker/regtest/eclair.sqlite new file mode 100644 index 000000000..d306fb0de Binary files /dev/null and b/docker/eclair/stacker/regtest/eclair.sqlite differ diff --git a/docker/eclair/stacker/regtest/last_jdbcurl b/docker/eclair/stacker/regtest/last_jdbcurl new file mode 100644 index 000000000..532c6c608 --- /dev/null +++ b/docker/eclair/stacker/regtest/last_jdbcurl @@ -0,0 +1 @@ +sqlite diff --git a/docker/eclair/stacker/regtest/network.sqlite b/docker/eclair/stacker/regtest/network.sqlite new file mode 100644 index 000000000..cc0c24676 Binary files /dev/null and b/docker/eclair/stacker/regtest/network.sqlite differ diff --git a/docker/lnd/router/regtest/admin.macaroon b/docker/lnd/router/regtest/admin.macaroon new file mode 100644 index 000000000..3cf21c095 Binary files /dev/null and b/docker/lnd/router/regtest/admin.macaroon differ diff --git a/docker/lnd/router/regtest/macaroons.db b/docker/lnd/router/regtest/macaroons.db new file mode 100644 index 000000000..c0bbb8867 Binary files /dev/null and b/docker/lnd/router/regtest/macaroons.db differ diff --git a/docker/lnd/router/regtest/wallet.db b/docker/lnd/router/regtest/wallet.db new file mode 100644 index 000000000..9961a6a3b Binary files /dev/null and b/docker/lnd/router/regtest/wallet.db differ diff --git a/docker/lnd/router/tls.cert b/docker/lnd/router/tls.cert new file mode 100644 index 000000000..a930bf2ad --- /dev/null +++ b/docker/lnd/router/tls.cert @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICRzCCAe2gAwIBAgIRALrTKBEy2NhGUue4RgGKhpgwCgYIKoZIzj0EAwIwODEf +MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNWRhMzQx +OTEwZDAyMB4XDTI0MTIwOTA4MzcxOVoXDTI2MDIwMzA4MzcxOVowODEfMB0GA1UE +ChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNWRhMzQxOTEwZDAy +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFPJk3jfBfWyHM7TB2pCJ45J5VqVI +r9x4nvBIZPQdvizgV4qqiNnnKTohZtH7eJ/T/epN3V9UNH3jW5MTcnIv+qOB1zCB +1DAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUN7Er1+iR3NeiwJXqLMD6CXb86qIwfQYDVR0RBHYw +dIIMNWRhMzQxOTEwZDAygglsb2NhbGhvc3SCCnJvdXRlcl9sbmSCFGhvc3QuZG9j +a2VyLmludGVybmFsggR1bml4ggp1bml4cGFja2V0ggdidWZjb25uhwR/AAABhxAA +AAAAAAAAAAAAAAAAAAABhwSsEgAJMAoGCCqGSM49BAMCA0gAMEUCIAucaM+ZivUy +G2PDcCfQZGDa0O8XVGQwofI2ZpMQwVe6AiEA9vYnOSZG1ozi0IKNgqbEs3ObByjE +dM+krTDuPzk8Kd4= +-----END CERTIFICATE----- diff --git a/docker/lnd/router/tls.key b/docker/lnd/router/tls.key new file mode 100644 index 000000000..d79948c47 --- /dev/null +++ b/docker/lnd/router/tls.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIBThCj41Abt/iEDYYMXb+mfHJmXN211JGYDjekJOmCbUoAoGCCqGSM49 +AwEHoUQDQgAEFPJk3jfBfWyHM7TB2pCJ45J5VqVIr9x4nvBIZPQdvizgV4qqiNnn +KTohZtH7eJ/T/epN3V9UNH3jW5MTcnIv+g== +-----END EC PRIVATE KEY----- diff --git a/docker/lndk/Dockerfile b/docker/lndk/Dockerfile new file mode 100644 index 000000000..a421053a6 --- /dev/null +++ b/docker/lndk/Dockerfile @@ -0,0 +1,17 @@ +# This image uses fedora 40 because the official pre-built lndk binaries require +# glibc 2.39 which is not available on debian or ubuntu images. +FROM fedora:40 +RUN useradd -u 1000 -m lndk + +RUN mkdir -p /home/lndk/.lndk +COPY ["./tls-*", "/home/lndk/.lndk"] +RUN chown 1000:1000 -Rvf /home/lndk/.lndk && \ + chmod 644 /home/lndk/.lndk/tls-cert.pem && \ + chmod 600 /home/lndk/.lndk/tls-key.pem + +USER lndk +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/lndk-org/lndk/releases/download/v0.2.0/lndk-installer.sh | sh +RUN echo 'source /home/lndk/.cargo/env' >> $HOME/.bashrc +WORKDIR /home/lndk +EXPOSE 7000 +ENV PATH="/home/lndk/.cargo/bin:${PATH}" \ No newline at end of file diff --git a/docker/lndk/tls-cert.pem b/docker/lndk/tls-cert.pem new file mode 100644 index 000000000..16202ceed --- /dev/null +++ b/docker/lndk/tls-cert.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBaDCCAQ2gAwIBAgIUOms3xZ+pBVUntnFD7J0m7Ll1MZYwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWduZWQgY2VydDAgFw03NTAxMDEwMDAw +MDBaGA80MDk2MDEwMTAwMDAwMFowITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWdu +ZWQgY2VydDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGdu9cXUGSPIycSCbmGb +6/4U+txvE0aSvzsMc+pKFiXlB+P/3x/WxYMxlHB0lh9fTQU8tdViJ2AY/QnHVwUk +O4CjITAfMB0GA1UdEQQWMBSCCWxvY2FsaG9zdIIHc25fbG5kazAKBggqhkjOPQQD +AgNJADBGAiEA78UdPHgdaXVyttqt21+uWTlFn4B6queGL/cmYpQbiIsCIQCwxY0n +x2v5zXEwPU/bOnaQNeq9F8AT+/4lKelHfON/Gw== +-----END CERTIFICATE----- diff --git a/docker/lndk/tls-key.pem b/docker/lndk/tls-key.pem new file mode 100644 index 000000000..d31388e73 --- /dev/null +++ b/docker/lndk/tls-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTa/r2pnmB05EwKk6 +a4FbigSagGBok+i/ASxkG9iGedWhRANCAARnbvXF1BkjyMnEgm5hm+v+FPrcbxNG +kr87DHPqShYl5Qfj/98f1sWDMZRwdJYfX00FPLXVYidgGP0Jx1cFJDuA +-----END PRIVATE KEY----- diff --git a/fragments/users.js b/fragments/users.js index a9309f57d..b8307a754 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -255,7 +255,7 @@ export const TOP_USERS = gql` photoId ncomments(when: $when, from: $from, to: $to) nposts(when: $when, from: $from, to: $to) - + proportion optional { stacked(when: $when, from: $from, to: $to) spent(when: $when, from: $from, to: $to) diff --git a/fragments/wallet.js b/fragments/wallet.js index 7fe3fc492..6f84f4afd 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -23,6 +23,7 @@ export const INVOICE_FIELDS = gql` actionError confirmedPreimage forwardedSats + forwardStatus }` export const INVOICE_FULL = gql` diff --git a/lib/cln.js b/lib/cln.js index 98138e3d9..7630aef06 100644 --- a/lib/cln.js +++ b/lib/cln.js @@ -2,30 +2,44 @@ import fetch from 'cross-fetch' import crypto from 'crypto' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson, assertResponseOk } from './url' +import { FetchTimeoutError } from './fetch' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants' -export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => { +export const createInvoice = async ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => { const agent = getAgent({ hostname: socket, cert }) const url = `${agent.protocol}//${socket}/v1/invoice` - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Rune: rune, - // can be any node id, only required for CLN v23.08 and below - // see https://docs.corelightning.org/docs/rest#server - nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490' - }, - agent, - body: JSON.stringify({ - // CLN requires a unique label for every invoice - // see https://docs.corelightning.org/reference/lightning-invoice - label: crypto.randomBytes(16).toString('hex'), - description, - amount_msat: msats, - expiry + + let res + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Rune: rune, + // can be any node id, only required for CLN v23.08 and below + // see https://docs.corelightning.org/docs/rest#server + nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490' + }, + agent, + body: JSON.stringify({ + // CLN requires a unique label for every invoice + // see https://docs.corelightning.org/reference/lightning-invoice + label: crypto.randomBytes(16).toString('hex'), + description, + amount_msat: msats, + expiry + }), + signal }) - }) + } catch (err) { + if (err.name === 'AbortError') { + // XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually. + // see https://github.com/node-fetch/node-fetch/issues/1462 + throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + throw err + } assertResponseOk(res) assertContentTypeJson(res) diff --git a/lib/constants.js b/lib/constants.js index 978343ea6..b828d26d8 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -46,6 +46,7 @@ export const MAX_POST_TEXT_LENGTH = 100000 // 100k export const MAX_COMMENT_TEXT_LENGTH = 10000 // 10k export const MAX_TERRITORY_DESC_LENGTH = 1000 // 1k export const MAX_POLL_CHOICE_LENGTH = 40 +export const ITEM_EDIT_SECONDS = 600 export const ITEM_SPAM_INTERVAL = '10m' export const ANON_ITEM_SPAM_INTERVAL = '0' export const INV_PENDING_LIMIT = 100 @@ -79,6 +80,7 @@ export const ANON_FEE_MULTIPLIER = 100 export const SSR = typeof window === 'undefined' export const MAX_FORWARDS = 5 export const LND_PATHFINDING_TIMEOUT_MS = 30000 +export const LND_PATHFINDING_TIME_PREF_PPM = 1e6 // optimize for reliability over fees export const LNURLP_COMMENT_MAX_LENGTH = 1000 // https://github.com/lightning/bolts/issues/236 export const MAX_INVOICE_DESCRIPTION_LENGTH = 640 @@ -189,3 +191,6 @@ export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTER export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL) export const ZAP_UNDO_DELAY_MS = 5_000 + +export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000 +export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000 diff --git a/lib/fetch.js b/lib/fetch.js index 2b3ed3300..91f815c20 100644 --- a/lib/fetch.js +++ b/lib/fetch.js @@ -1,14 +1,28 @@ -export async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) { - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeout) - - const response = await fetch(resource, { - ...options, - signal: controller.signal - }) - clearTimeout(id) +import { TimeoutError, timeoutSignal } from '@/lib/time' + +export class FetchTimeoutError extends TimeoutError { + constructor (method, url, timeout) { + super(timeout) + this.name = 'FetchTimeoutError' + this.message = timeout + ? `${method} ${url}: timeout after ${timeout / 1000}s` + : `${method} ${url}: timeout` + } +} - return response +export async function fetchWithTimeout (resource, { signal, timeout = 1000, ...options } = {}) { + try { + return await fetch(resource, { + ...options, + signal: signal ?? timeoutSignal(timeout) + }) + } catch (err) { + if (err.name === 'TimeoutError') { + // use custom error message + throw new FetchTimeoutError(options.method ?? 'GET', resource, err.timeout) + } + throw err + } } class LRUCache { diff --git a/lib/item.js b/lib/item.js index e44b53a3c..33b0111ea 100644 --- a/lib/item.js +++ b/lib/item.js @@ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => { return 'hot' } -export const isJob = item => item.subName !== 'jobs' +export const isJob = item => item.subName === 'jobs' // a delete directive preceded by a non word character that isn't a backtick const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi diff --git a/lib/lnurl.js b/lib/lnurl.js index 38b172dc8..930568d64 100644 --- a/lib/lnurl.js +++ b/lib/lnurl.js @@ -1,6 +1,8 @@ import { createHash } from 'crypto' import { bech32 } from 'bech32' import { lnAddrSchema } from './validate' +import { FetchTimeoutError } from '@/lib/fetch' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants' export function encodeLNUrl (url) { const words = bech32.toWords(Buffer.from(url.toString(), 'utf8')) @@ -25,7 +27,7 @@ export function lnurlPayDescriptionHash (data) { return createHash('sha256').update(data).digest('hex') } -export async function lnAddrOptions (addr) { +export async function lnAddrOptions (addr, { signal } = {}) { await lnAddrSchema().fields.addr.validate(addr) const [name, domain] = addr.split('@') let protocol = 'https' @@ -35,12 +37,16 @@ export async function lnAddrOptions (addr) { } const unexpectedErrorMessage = `An unexpected error occurred fetching the Lightning Address metadata for ${addr}. Check the address and try again.` let res + const url = `${protocol}://${domain}/.well-known/lnurlp/${name}` try { - const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`) + const req = await fetch(url, { signal }) res = await req.json() } catch (err) { - // If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error console.log('Error fetching lnurlp', err) + if (err.name === 'TimeoutError') { + throw new FetchTimeoutError('GET', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + // If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error throw new Error(unexpectedErrorMessage) } if (res.status === 'ERROR') { diff --git a/lib/nostr.js b/lib/nostr.js index 7a5e497a0..077e658b1 100644 --- a/lib/nostr.js +++ b/lib/nostr.js @@ -1,8 +1,6 @@ import { bech32 } from 'bech32' import { nip19 } from 'nostr-tools' -import WebSocket from 'isomorphic-ws' -import { callWithTimeout, withTimeout } from '@/lib/time' -import crypto from 'crypto' +import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk' export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/ export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/ @@ -17,151 +15,140 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [ 'wss://nostr.mutinywallet.com/', 'wss://relay.mutinywallet.com/' ] - -export class Relay { - constructor (relayUrl) { - const ws = new WebSocket(relayUrl) - - ws.onmessage = (msg) => { - const [type, notice] = JSON.parse(msg.data) - if (type === 'NOTICE') { - console.log('relay notice:', notice) - } - } - - ws.onerror = (err) => { - console.error('websocket error:', err.message) - this.error = err.message - } - - this.ws = ws - this.url = relayUrl - this.error = null - } - - static async connect (url, { timeout } = {}) { - const relay = new Relay(url) - await relay.waitUntilConnected({ timeout }) - return relay +export const RELAYS_BLACKLIST = [] + +/* eslint-disable camelcase */ + +/** + * @import {NDKSigner} from '@nostr-dev-kit/ndk' + * @import { NDK } from '@nostr-dev-kit/ndk' + * @import {NDKNwc} from '@nostr-dev-kit/ndk' + * @typedef {Object} Nostr + * @property {NDK} ndk + * @property {function(string, {logger: Object}): Promise} nwc + * @property {function(Object, {privKey: string, signer: NDKSigner}): Promise} sign + * @property {function(Object, {relays: Array, privKey: string, signer: NDKSigner}): Promise} publish + */ +export default class Nostr { + /** + * @type {NDK} + */ + _ndk = null + + constructor ({ privKey, defaultSigner, relays, nip46token, supportNip07 = false, ...ndkOptions } = {}) { + this._ndk = new NDK({ + explicitRelayUrls: relays, + blacklistRelayUrls: RELAYS_BLACKLIST, + autoConnectUserRelays: false, + autoFetchUserMutelist: false, + clientName: 'stacker.news', + signer: defaultSigner ?? this.getSigner({ privKey, supportNip07, nip46token }), + ...ndkOptions + }) } - get connected () { - return this.ws.readyState === WebSocket.OPEN + /** + * @type {NDK} + */ + get ndk () { + return this._ndk } - get closed () { - return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED + /** + * + * @param {Object} param0 + * @param {string} [args.privKey] - private key to use for signing + * @param {string} [args.nip46token] - NIP-46 token to use for signing + * @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available + * @returns {NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | null} - a signer instance + */ + getSigner ({ privKey, nip46token, supportNip07 = true } = {}) { + if (privKey) return new NDKPrivateKeySigner(privKey) + if (nip46token) return new NDKNip46SignerURLPatch(this.ndk, nip46token) + if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer() + return null } - async waitUntilConnected ({ timeout } = {}) { - let interval - - const checkPromise = new Promise((resolve, reject) => { - interval = setInterval(() => { - if (this.connected) { - resolve() - } - if (this.closed) { - reject(new Error(`failed to connect to ${this.url}: ` + this.error)) - } - }, 100) + /** + * @param {Object} rawEvent + * @param {number} rawEvent.kind + * @param {number} rawEvent.created_at + * @param {string} rawEvent.content + * @param {Array>} rawEvent.tags + * @param {Object} context + * @param {string} context.privKey + * @param {NDKSigner} context.signer + * @returns {Promise} + */ + async sign ({ kind, created_at, content, tags }, { signer } = {}) { + const event = new NDKEvent(this.ndk, { + kind, + created_at, + content, + tags }) - - try { - return await withTimeout(checkPromise, timeout) - } catch (err) { - this.close() - throw err - } finally { - clearInterval(interval) - } - } - - close () { - const state = this.ws.readyState - if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) { - this.ws.close() - } + signer ??= this.ndk.signer + if (!signer) throw new Error('no way to sign this event, please provide a signer or private key') + await event.sign(signer) + return event } - async publish (event, { timeout } = {}) { - const ws = this.ws - - let listener - const ackPromise = new Promise((resolve, reject) => { - listener = function onmessage (msg) { - const [type, eventId, accepted, reason] = JSON.parse(msg.data) + /** + * @param {Object} rawEvent + * @param {number} rawEvent.kind + * @param {number} rawEvent.created_at + * @param {string} rawEvent.content + * @param {Array>} rawEvent.tags + * @param {Object} context + * @param {Array} context.relays + * @param {string} context.privKey + * @param {NDKSigner} context.signer + * @param {number} context.timeout + * @returns {Promise} + */ + async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) { + const event = await this.sign({ kind, created_at, content, tags }, { signer }) - if (type !== 'OK' || eventId !== event.id) return - - if (accepted) { - resolve(eventId) - } else { - reject(new Error(reason || `event rejected: ${eventId}`)) - } - } + const successfulRelays = [] + const failedRelays = [] - ws.addEventListener('message', listener) + const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true) - ws.send(JSON.stringify(['EVENT', event])) + event.on('relay:publish:failed', (relay, error) => { + failedRelays.push({ relay: relay.url, error }) }) - try { - return await withTimeout(ackPromise, timeout) - } finally { - ws.removeEventListener('message', listener) + for (const relay of (await relaySet.publish(event, timeout))) { + successfulRelays.push(relay.url) + } + + return { + event, + successfulRelays, + failedRelays } } - async fetch (filter, { timeout } = {}) { - const ws = this.ws - - let listener - const ackPromise = new Promise((resolve, reject) => { - const id = crypto.randomBytes(16).toString('hex') - - const events = [] - let eose = false - - listener = function onmessage (msg) { - const [type, subId, event] = JSON.parse(msg.data) - - if (subId !== id) return - - if (type === 'EVENT') { - events.push(event) - if (eose) { - // EOSE was already received: - // return first event after EOSE - resolve(events) - } - return - } - - if (type === 'CLOSED') { - return resolve(events) - } - - if (type === 'EOSE') { - eose = true - if (events.length > 0) { - // we already received events before EOSE: - // return all events before EOSE - ws.send(JSON.stringify(['CLOSE', id])) - return resolve(events) - } - } + async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) { + try { + signer ??= this.getSigner({ supportNip07: true }) + const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout }) + + let noteId = null + if (signedEvent.kind !== 1) { + noteId = await nip19.naddrEncode({ + kind: signedEvent.kind, + pubkey: signedEvent.pubkey, + identifier: signedEvent.tags[0][1] + }) + } else { + noteId = hexToBech32(signedEvent.id, 'note') } - ws.addEventListener('message', listener) - - ws.send(JSON.stringify(['REQ', id, ...filter])) - }) - - try { - return await withTimeout(ackPromise, timeout) - } finally { - ws.removeEventListener('message', listener) + return { successfulRelays, failedRelays, noteId } + } catch (error) { + console.error('Crosspost error:', error) + return { error } } } } @@ -187,48 +174,10 @@ export function nostrZapDetails (zap) { return { npub, content, note } } -async function publishNostrEvent (signedEvent, relayUrl) { - const timeout = 3000 - const relay = await Relay.connect(relayUrl, { timeout }) - try { - await relay.publish(signedEvent, { timeout }) - } finally { - relay.close() - } -} - -export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) { - try { - const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 10000) - if (!signedEvent) throw new Error('failed to sign event') - - const promises = relays.map(r => publishNostrEvent(signedEvent, r)) - const results = await Promise.allSettled(promises) - const successfulRelays = [] - const failedRelays = [] - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - successfulRelays.push(relays[index]) - } else { - failedRelays.push({ relay: relays[index], error: result.reason }) - } - }) - - let noteId = null - if (signedEvent.kind !== 1) { - noteId = await nip19.naddrEncode({ - kind: signedEvent.kind, - pubkey: signedEvent.pubkey, - identifier: signedEvent.tags[0][1] - }) - } else { - noteId = hexToBech32(signedEvent.id, 'note') - } - - return { successfulRelays, failedRelays, noteId } - } catch (error) { - console.error('Crosspost error:', error) - return { error } +// workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636) +class NDKNip46SignerURLPatch extends NDKNip46Signer { + connectionTokenInit (connectionToken) { + connectionToken = connectionToken.replace('bunker://', 'http://') + return super.connectionTokenInit(connectionToken) } } diff --git a/lib/time.js b/lib/time.js index 22589c6b2..cce8f7566 100644 --- a/lib/time.js +++ b/lib/time.js @@ -128,12 +128,22 @@ function tzOffset (tz) { return targetOffsetHours } +export class TimeoutError extends Error { + constructor (timeout) { + super(`timeout after ${timeout / 1000}s`) + this.name = 'TimeoutError' + this.timeout = timeout + } +} + function timeoutPromise (timeout) { return new Promise((resolve, reject) => { // if no timeout is specified, never settle if (!timeout) return - setTimeout(() => reject(new Error(`timeout after ${timeout / 1000}s`)), timeout) + // delay timeout by 100ms so any parallel promise with same timeout will throw first + const delay = 100 + setTimeout(() => reject(new TimeoutError(timeout)), timeout + delay) }) } @@ -144,3 +154,16 @@ export async function withTimeout (promise, timeout) { export async function callWithTimeout (fn, timeout) { return await Promise.race([fn(), timeoutPromise(timeout)]) } + +// AbortSignal.timeout with our custom timeout error message +export function timeoutSignal (timeout) { + const controller = new AbortController() + + if (timeout) { + setTimeout(() => { + controller.abort(new TimeoutError(timeout)) + }, timeout) + } + + return controller.signal +} diff --git a/lib/url.js b/lib/url.js index d6eca9032..3bb51e3f2 100644 --- a/lib/url.js +++ b/lib/url.js @@ -203,12 +203,12 @@ export function parseNwcUrl (walletConnectUrl) { const params = {} params.walletPubkey = url.host const secret = url.searchParams.get('secret') - const relayUrl = url.searchParams.get('relay') + const relayUrls = url.searchParams.getAll('relay') if (secret) { params.secret = secret } - if (relayUrl) { - params.relayUrl = relayUrl + if (relayUrls) { + params.relayUrls = relayUrls } return params } diff --git a/lib/yup.js b/lib/yup.js index fbbf0b12e..745cb5df6 100644 --- a/lib/yup.js +++ b/lib/yup.js @@ -147,15 +147,15 @@ addMethod(string, 'nwcUrl', function () { // 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 relayUrl, walletPubkey, secret + let relayUrls, walletPubkey, secret try { - ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) + ({ 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) - string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl) + 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 }) diff --git a/package-lock.json b/package-lock.json index 47a763f06..6bb7b2873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", + "@nostr-dev-kit/ndk": "^2.10.5", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", @@ -4372,6 +4373,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/secp256k1": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.1.0.tgz", + "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4407,6 +4417,49 @@ "node": ">= 8" } }, + "node_modules/@nostr-dev-kit/ndk": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.5.tgz", + "integrity": "sha512-QEnarJL9BGCxeenSIE9jxNSDyYQYjzD30YL3sVJ9cNybNZX8tl/I1/vhEUeRRMBz/qjROLtt0M2RV68rZ205tg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.6", + "light-bolt11-decoder": "^3.2.0", + "nostr-tools": "^2.7.1", + "tseep": "^1.2.2", + "typescript-lru-cache": "^2.0.0", + "utf8-buffer": "^1.0.0", + "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@opensearch-project/opensearch": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-2.12.0.tgz", @@ -7311,6 +7364,19 @@ "node": ">=4" } }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/builtins": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", @@ -8090,6 +8156,19 @@ "node": ">= 10" } }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -8969,6 +9048,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/esbuild": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", @@ -9582,6 +9701,21 @@ "node": ">=6" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -9676,6 +9810,16 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -9830,6 +9974,15 @@ } ] }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -14155,6 +14308,15 @@ "node": ">= 0.8.0" } }, + "node_modules/light-bolt11-decoder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", + "dependencies": { + "@scure/base": "1.1.1" + } + }, "node_modules/lightning": { "version": "10.22.0", "resolved": "https://registry.npmjs.org/lightning/-/lightning-10.22.0.tgz", @@ -15607,6 +15769,12 @@ "react-dom": ">=16.0.0" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -19433,11 +19601,23 @@ "resolved": "https://registry.npmjs.org/tsdef/-/tsdef-0.0.14.tgz", "integrity": "sha512-UjMD4XKRWWFlFBfwKVQmGFT5YzW/ZaF8x6KpCDf92u9wgKeha/go3FU0e5WqDjXsCOdfiavCkfwfVHNDxRDGMA==" }, + "node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==" }, + "node_modules/tstl": { + "version": "2.5.16", + "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz", + "integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.19.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz", @@ -19477,6 +19657,12 @@ "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19599,11 +19785,26 @@ "node": ">= 18" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" }, + "node_modules/typescript-lru-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz", + "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", + "license": "MIT" + }, "node_modules/uint8array-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", @@ -20022,6 +20223,28 @@ } } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/utf8-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utf8-buffer/-/utf8-buffer-1.0.0.tgz", + "integrity": "sha512-ueuhzvWnp5JU5CiGSY4WdKbiN/PO2AZ/lpeLiz2l38qwdLy/cW40XobgyuIWucNyum0B33bVB0owjFCeGBSLqg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/util": { "version": "0.12.4", "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", @@ -20345,6 +20568,47 @@ "npm": ">=3.10.0" } }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket-polyfill": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", + "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", + "dependencies": { + "tstl": "^2.0.7", + "websocket": "^1.0.28" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -20921,6 +21185,15 @@ "node": ">=10" } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 1b9189c3a..241365f70 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", + "@nostr-dev-kit/ndk": "^2.10.5", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", diff --git a/pages/rewards/index.js b/pages/rewards/index.js index 164710f4c..a81d88437 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -16,7 +16,6 @@ import { useToast } from '@/components/toast' import { useLightning } from '@/components/lightning' import { ListUsers } from '@/components/user-list' import { Col, Row } from 'react-bootstrap' -import { proportions } from '@/lib/madness' import { useData } from '@/components/use-data' import { GrowthPieChartSkeleton } from '@/components/charts-skeletons' import { useMemo } from 'react' @@ -50,6 +49,7 @@ ${ITEM_FULL_FIELDS} photoId ncomments nposts + proportion optional { streak @@ -117,9 +117,10 @@ export default function Rewards ({ ssrData }) { if (!dat) return - function EstimatedReward ({ rank }) { - const referrerReward = Math.floor(total * proportions[rank - 1] * 0.2) - const reward = Math.floor(total * proportions[rank - 1]) - referrerReward + function EstimatedReward ({ rank, user }) { + if (!user) return null + const referrerReward = Math.max(Math.floor(total * user.proportion * 0.2), 0) + const reward = Math.max(Math.floor(total * user.proportion) - referrerReward, 0) return (
diff --git a/prisma/migrations/20241206155927_invoice_predecessors/migration.sql b/prisma/migrations/20241206155927_invoice_predecessors/migration.sql new file mode 100644 index 000000000..20260a451 --- /dev/null +++ b/prisma/migrations/20241206155927_invoice_predecessors/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[predecessorId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "predecessorId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice.predecessorId_unique" ON "Invoice"("predecessorId"); + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241217163642_user_values_improve/migration.sql b/prisma/migrations/20241217163642_user_values_improve/migration.sql new file mode 100644 index 000000000..47f21be36 --- /dev/null +++ b/prisma/migrations/20241217163642_user_values_improve/migration.sql @@ -0,0 +1,93 @@ +CREATE OR REPLACE FUNCTION user_values( + min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT, + percentile_cutoff INTEGER DEFAULT 50, + each_upvote_portion FLOAT DEFAULT 4.0, + each_item_portion FLOAT DEFAULT 4.0, + handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}', + handicap_zap_mult FLOAT DEFAULT 0.3) +RETURNS TABLE ( + t TIMESTAMP(3), id INTEGER, proportion FLOAT +) +LANGUAGE plpgsql +AS $$ +DECLARE + min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago'); +BEGIN + RETURN QUERY + SELECT period.t, u."userId", u.total_proportion + FROM generate_series(min, max, ival) period(t), + LATERAL + (WITH item_ratios AS ( + SELECT *, + CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type, + CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio + FROM ( + SELECT *, + NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile, + ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank + FROM + "Item" + WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t + AND "weightedVotes" > 0 + AND "deletedAt" IS NULL + AND NOT bio + AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID') + ) x + WHERE x.percentile <= percentile_cutoff + ), + -- get top upvoters of top posts and comments + upvoter_islands AS ( + SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId", + "ItemAct".msats as tipped, "ItemAct".created_at as acted_at, + ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc) + - ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island + FROM item_ratios + JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id + WHERE act = 'TIP' + AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t + AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') + ), + -- isolate contiguous upzaps from the same user on the same item so that when we take the log + -- of the upzaps it accounts for successive zaps and does not disproportionately reward them + -- quad root of the total tipped + upvoters AS ( + SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at + FROM upvoter_islands + GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island + ), + -- the relative contribution of each upvoter to the post/comment + -- early component: 1/ln(early_rank + e - 1) + -- tipped component: how much they tipped relative to the total tipped for the item + -- multiplied by the relative rank of the item to the total items + -- multiplied by the trust of the user + upvoter_ratios AS ( + SELECT "userId", sum((early_multiplier+tipped_ratio)*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE users.trust+0.1 END) as upvoter_ratio, + "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type + FROM ( + SELECT *, + 1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier, + tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio + FROM upvoters + WHERE tipped > 2.1 + ) u + JOIN users on "userId" = users.id + GROUP BY "userId", "parentId" IS NULL + ), + proportions AS ( + SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank, + upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion + FROM upvoter_ratios + WHERE upvoter_ratio > 0 + UNION ALL + SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion + FROM item_ratios + ) + SELECT "userId", sum(proportions.proportion) AS total_proportion + FROM proportions + GROUP BY "userId" + HAVING sum(proportions.proportion) > 0.000001) u; +END; +$$; + +REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today; +REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days; \ No newline at end of file diff --git a/prisma/migrations/20241219002217_improve_values_again/migration.sql b/prisma/migrations/20241219002217_improve_values_again/migration.sql new file mode 100644 index 000000000..2e8bfa37c --- /dev/null +++ b/prisma/migrations/20241219002217_improve_values_again/migration.sql @@ -0,0 +1,94 @@ +CREATE OR REPLACE FUNCTION user_values( + min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT, + percentile_cutoff INTEGER DEFAULT 50, + each_upvote_portion FLOAT DEFAULT 4.0, + each_item_portion FLOAT DEFAULT 4.0, + handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}', + handicap_zap_mult FLOAT DEFAULT 0.3) +RETURNS TABLE ( + t TIMESTAMP(3), id INTEGER, proportion FLOAT +) +LANGUAGE plpgsql +AS $$ +DECLARE + min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago'); +BEGIN + RETURN QUERY + SELECT period.t, u."userId", u.total_proportion + FROM generate_series(min, max, ival) period(t), + LATERAL + (WITH item_ratios AS ( + SELECT *, + CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type, + CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio + FROM ( + SELECT *, + NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile, + ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank + FROM + "Item" + WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t + AND "weightedVotes" > 0 + AND "deletedAt" IS NULL + AND NOT bio + AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID') + ) x + WHERE x.percentile <= percentile_cutoff + ), + -- get top upvoters of top posts and comments + upvoter_islands AS ( + SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId", + "ItemAct".msats as tipped, "ItemAct".created_at as acted_at, + ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc) + - ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island + FROM item_ratios + JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id + WHERE act = 'TIP' + AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t + AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') + ), + -- isolate contiguous upzaps from the same user on the same item so that when we take the log + -- of the upzaps it accounts for successive zaps and does not disproportionately reward them + -- quad root of the total tipped + upvoters AS ( + SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at + FROM upvoter_islands + GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island + HAVING CASE WHEN "parentId" IS NULL THEN sum(tipped) / 1000 > 40 ELSE sum(tipped) / 1000 > 20 END + ), + -- the relative contribution of each upvoter to the post/comment + -- early component: 1/ln(early_rank + e - 1) + -- tipped component: how much they tipped relative to the total tipped for the item + -- multiplied by the relative rank of the item to the total items + -- multiplied by the trust of the user + upvoter_ratios AS ( + SELECT "userId", sum((2*early_multiplier+1)*tipped_ratio*ratio) as upvoter_ratio, + "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type + FROM ( + SELECT *, + 1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier, + tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio + FROM upvoters + WHERE tipped > 0 + ) u + JOIN users on "userId" = users.id + GROUP BY "userId", "parentId" IS NULL + ), + proportions AS ( + SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank, + upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion + FROM upvoter_ratios + WHERE upvoter_ratio > 0 + UNION ALL + SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion + FROM item_ratios + ) + SELECT "userId", sum(proportions.proportion) AS total_proportion + FROM proportions + GROUP BY "userId" + HAVING sum(proportions.proportion) > 0.000001) u; +END; +$$; + +REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today; +REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2aac87a0c..390498324 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -905,39 +905,41 @@ model ItemMention { } model Invoice { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - hash String @unique(map: "Invoice.hash_unique") - preimage String? @unique(map: "Invoice.preimage_unique") - isHeld Boolean? - bolt11 String - expiresAt DateTime - confirmedAt DateTime? - confirmedIndex BigInt? - cancelled Boolean @default(false) - cancelledAt DateTime? - msatsRequested BigInt - msatsReceived BigInt? - desc String? - comment String? - lud18Data Json? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - invoiceForward InvoiceForward? - - actionState InvoiceActionState? - actionType InvoiceActionType? - actionOptimistic Boolean? - actionId Int? - actionArgs Json? @db.JsonB - actionError String? - actionResult Json? @db.JsonB - ItemAct ItemAct[] - Item Item[] - Upload Upload[] - PollVote PollVote[] - PollBlindVote PollBlindVote[] + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int + hash String @unique(map: "Invoice.hash_unique") + preimage String? @unique(map: "Invoice.preimage_unique") + isHeld Boolean? + bolt11 String + expiresAt DateTime + confirmedAt DateTime? + confirmedIndex BigInt? + cancelled Boolean @default(false) + cancelledAt DateTime? + msatsRequested BigInt + msatsReceived BigInt? + desc String? + comment String? + lud18Data Json? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + invoiceForward InvoiceForward? + predecessorId Int? @unique(map: "Invoice.predecessorId_unique") + predecessorInvoice Invoice? @relation("PredecessorInvoice", fields: [predecessorId], references: [id], onDelete: Cascade) + successorInvoice Invoice? @relation("PredecessorInvoice") + actionState InvoiceActionState? + actionType InvoiceActionType? + actionOptimistic Boolean? + actionId Int? + actionArgs Json? @db.JsonB + actionError String? + actionResult Json? @db.JsonB + ItemAct ItemAct[] + Item Item[] + Upload Upload[] + PollVote PollVote[] + PollBlindVote PollBlindVote[] @@index([createdAt], map: "Invoice.created_at_index") @@index([userId], map: "Invoice.userId_index") diff --git a/scripts/test-routing.sh b/scripts/test-routing.sh new file mode 100644 index 000000000..7ce7b75b6 --- /dev/null +++ b/scripts/test-routing.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# test if every node can pay invoices from every other node + +SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490 +LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35 +CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90 + +# -e: exit on first failure | -x: print commands +set -ex + +sndev cli lnd queryroutes $SN_LND_PUBKEY 1000 +sndev cli lnd queryroutes $CLN_PUBKEY 1000 + +sndev cli sn_lnd queryroutes $LND_PUBKEY 1000 +sndev cli sn_lnd queryroutes $CLN_PUBKEY 1000 + +# https://docs.corelightning.org/reference/lightning-getroute +sndev cli cln getroute $LND_PUBKEY 1000 0 +sndev cli cln getroute $SN_LND_PUBKEY 1000 0 diff --git a/wallets/blink/client.js b/wallets/blink/client.js index 8779d64ae..c5a487b83 100644 --- a/wallets/blink/client.js +++ b/wallets/blink/client.js @@ -1,9 +1,10 @@ import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' export * from '@/wallets/blink' -export async function testSendPayment ({ apiKey, currency }, { logger }) { +export async function testSendPayment ({ apiKey, currency }, { logger, signal }) { logger.info('trying to fetch ' + currency + ' wallet') - const scopes = await getScopes(apiKey) + + const scopes = await getScopes({ apiKey }, { signal }) if (!scopes.includes(SCOPE_READ)) { throw new Error('missing READ scope') } @@ -12,46 +13,48 @@ export async function testSendPayment ({ apiKey, currency }, { logger }) { } currency = currency ? currency.toUpperCase() : 'BTC' - await getWallet(apiKey, currency) + await getWallet({ apiKey, currency }, { signal }) logger.ok(currency + ' wallet found') } -export async function sendPayment (bolt11, { apiKey, currency }) { - const wallet = await getWallet(apiKey, currency) - return await payInvoice(apiKey, wallet, bolt11) +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 (authToken, wallet, invoice) { - const walletId = wallet.id - const out = await request(authToken, ` - mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { +async function payInvoice (bolt11, { apiKey, wallet }, { signal }) { + const out = await request({ + apiKey, + query: ` + mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { lnInvoicePaymentSend(input: $input) { - status - errors { - message - path - code - } - transaction { - settlementVia { - ... on SettlementViaIntraLedger { - preImage - } - ... on SettlementViaLn { - preImage - } - } + status + errors { + message + path + code + } + transaction { + settlementVia { + ... on SettlementViaIntraLedger { + preImage + } + ... on SettlementViaLn { + preImage + } } + } } + }`, + variables: { + input: { + paymentRequest: bolt11, + walletId: wallet.id + } } - `, - { - input: { - paymentRequest: invoice, - walletId - } - }) + }, { signal }) + const status = out.data.lnInvoicePaymentSend.status const errors = out.data.lnInvoicePaymentSend.errors if (errors && errors.length > 0) { @@ -76,7 +79,7 @@ async function payInvoice (authToken, wallet, invoice) { // at some point it should either be settled or fail on the backend, so the loop will exit await new Promise(resolve => setTimeout(resolve, 100)) - const txInfo = await getTxInfo(authToken, wallet, invoice) + const txInfo = await getTxInfo(bolt11, { apiKey, wallet }, { signal }) // settled if (txInfo.status === 'SUCCESS') { if (!txInfo.preImage) throw new Error('no preimage') @@ -95,36 +98,37 @@ async function payInvoice (authToken, wallet, invoice) { throw new Error('unexpected error') } -async function getTxInfo (authToken, wallet, invoice) { - const walletId = wallet.id +async function getTxInfo (bolt11, { apiKey, wallet }, { signal }) { let out try { - out = await request(authToken, ` - query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) { - me { - defaultAccount { - walletById(walletId: $walletId) { - transactionsByPaymentRequest(paymentRequest: $paymentRequest) { - status - direction - settlementVia { + out = await request({ + apiKey, + query: ` + query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) { + me { + defaultAccount { + walletById(walletId: $walletId) { + transactionsByPaymentRequest(paymentRequest: $paymentRequest) { + status + direction + settlementVia { ... on SettlementViaIntraLedger { - preImage + preImage } ... on SettlementViaLn { - preImage + preImage } + } } } } } - } + }`, + variables: { + paymentRequest: bolt11, + walletId: wallet.Id } - `, - { - paymentRequest: invoice, - walletId - }) + }, { signal }) } catch (e) { // something went wrong during the query, // maybe the connection was lost, so we just return diff --git a/wallets/blink/common.js b/wallets/blink/common.js index bf03f0786..d0e46c3d3 100644 --- a/wallets/blink/common.js +++ b/wallets/blink/common.js @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export const galoyBlinkUrl = 'https://api.blink.sv/graphql' @@ -7,38 +8,42 @@ export const SCOPE_READ = 'READ' export const SCOPE_WRITE = 'WRITE' export const SCOPE_RECEIVE = 'RECEIVE' -export async function getWallet (authToken, currency) { - const out = await request(authToken, ` +export async function getWallet ({ apiKey, currency }, { signal }) { + const out = await request({ + apiKey, + query: ` query me { - me { - defaultAccount { - wallets { - id - walletCurrency - } - } + 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 (authToken, query, variables = {}) { - const options = { +export async function request ({ apiKey, query, variables = {} }, { signal }) { + const res = await fetchWithTimeout(galoyBlinkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-KEY': authToken + 'X-API-KEY': apiKey }, - body: JSON.stringify({ query, variables }) - } - const res = await fetch(galoyBlinkUrl, options) + body: JSON.stringify({ query, variables }), + signal + }) assertResponseOk(res) assertContentTypeJson(res) @@ -46,14 +51,16 @@ export async function request (authToken, query, variables = {}) { return res.json() } -export async function getScopes (authToken) { - const out = await request(authToken, ` - query scopes { +export async function getScopes ({ apiKey }, { signal }) { + const out = await request({ + apiKey, + query: ` + query scopes { authorization { - scopes + scopes } - } - `, {}) + }` + }, { signal }) const scopes = out?.data?.authorization?.scopes return scopes || [] } diff --git a/wallets/blink/server.js b/wallets/blink/server.js index 937a7ebad..0d1f2748f 100644 --- a/wallets/blink/server.js +++ b/wallets/blink/server.js @@ -1,10 +1,9 @@ -import { withTimeout } from '@/lib/time' import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' import { msatsToSats } from '@/lib/format' export * from '@/wallets/blink' -export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { - const scopes = await getScopes(apiKeyRecv) +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') } @@ -15,47 +14,50 @@ export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { throw new Error('missing RECEIVE scope') } - const timeout = 15_000 currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' - return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }), timeout) + return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) } export async function createInvoice ( { msats, description, expiry }, - { apiKeyRecv, currencyRecv }) { - currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' + { apiKeyRecv: apiKey, currencyRecv: currency }, + { signal }) { + currency = currency ? currency.toUpperCase() : 'BTC' - const wallet = await getWallet(apiKeyRecv, currencyRecv) + const wallet = await getWallet({ apiKey, currency }, { signal }) - if (currencyRecv !== 'BTC') { - throw new Error('unsupported currency ' + currencyRecv) + if (currency !== 'BTC') { + throw new Error('unsupported currency ' + currency) } - const mutation = ` - mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { - lnInvoiceCreate(input: $input) { - invoice { - paymentRequest - } - errors { - message - } - } + + const out = await request({ + apiKey, + query: ` + mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { + lnInvoiceCreate(input: $input) { + invoice { + paymentRequest + } + errors { + message + } } - ` - - const out = await request(apiKeyRecv, mutation, { - input: { - amount: msatsToSats(msats), - expiresIn: Math.floor(expiry / 60) || 1, - memo: description, - walletId: wallet.id + }`, + variables: { + input: { + amount: msatsToSats(msats), + expiresIn: Math.floor(expiry / 60) || 1, + memo: description, + walletId: wallet.id + } } - }) + }, { signal }) + const res = out.data.lnInvoiceCreate const errors = res.errors if (errors && errors.length > 0) { throw new Error(errors.map(e => e.code + ' ' + e.message).join(', ')) } - const invoice = res.invoice.paymentRequest - return invoice + + return res.invoice.paymentRequest } diff --git a/wallets/cln/server.js b/wallets/cln/server.js index 53b958b18..2916e944b 100644 --- a/wallets/cln/server.js +++ b/wallets/cln/server.js @@ -2,23 +2,26 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' export * from '@/wallets/cln' -export const testCreateInvoice = async ({ socket, rune, cert }) => { - return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }) +export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { + return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal }) } export const createInvoice = async ( - { msats, description, descriptionHash, expiry }, - { socket, rune, cert } -) => { - const inv = await clnCreateInvoice({ - socket, - rune, - cert, - description, - descriptionHash, - msats, - expiry - }) + { msats, description, expiry }, + { socket, rune, cert }, + { signal }) => { + const inv = await clnCreateInvoice( + { + msats, + description, + expiry + }, + { + socket, + rune, + cert + }, + { signal }) return inv.bolt11 } diff --git a/wallets/config.js b/wallets/config.js index 8ddcfc71f..cdc574240 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -8,6 +8,8 @@ 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() @@ -37,17 +39,28 @@ export function useWalletConfigurator (wallet) { let serverConfig = serverWithShared if (canSend({ def: wallet.def, config: clientConfig })) { - let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true }) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - if (wallet.def.testSendPayment && validateLightning) { - transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) + try { + let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true }) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } - // validate again to ensure generated fields are valid - await validateWallet(wallet.def, clientConfig) + 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) @@ -71,33 +84,52 @@ export function useWalletConfigurator (wallet) { }, [me?.id, wallet.def.name, reloadLocalWallets]) const save = useCallback(async (newConfig, validateLightning = true) => { - const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning) + 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(serverConfig, clientConfig, validateLightning) + await _saveToServer(newServerConfig, newClientConfig, validateLightning) await _detachFromLocal() } else { - if (canSend({ def: wallet.def, config: clientConfig })) { - await _saveToLocal(clientConfig) + if (newCanSend) { + await _saveToLocal(newClientConfig) } else { // if it previously had a client config, remove it await _detachFromLocal() } - if (canReceive({ def: wallet.def, config: serverConfig })) { - await _saveToServer(serverConfig, clientConfig, validateLightning) + 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(serverConfig, clientConfig, validateLightning) + await _saveToServer(newServerConfig, newClientConfig, validateLightning) } else { // we previously had a server config without vault entries, remove it await _detachFromServer() } } } - }, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate, + + if (newCanSend) { + 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]) const detach = useCallback(async () => { @@ -112,7 +144,9 @@ export function useWalletConfigurator (wallet) { // if vault is not active and has a client config, delete from local storage await _detachFromLocal() } - }, [isActive, _detachFromServer, _detachFromLocal]) + + logger.info('details for sending deleted') + }, [logger, isActive, _detachFromServer, _detachFromLocal]) return { save, detach } } diff --git a/wallets/errors.js b/wallets/errors.js index 510d8e78b..13c9ca18c 100644 --- a/wallets/errors.js +++ b/wallets/errors.js @@ -47,6 +47,14 @@ export class WalletSenderError extends WalletPaymentError { } } +export class WalletReceiverError extends WalletPaymentError { + constructor (invoice) { + super(`payment forwarding failed for invoice ${invoice.hash}`) + this.name = 'WalletReceiverError' + this.invoice = invoice + } +} + export class WalletsNotAvailableError extends WalletConfigurationError { constructor () { super('no wallet available') diff --git a/wallets/index.js b/wallets/index.js index dbf6f45b3..bdf00083b 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -220,7 +220,7 @@ export function useWallet (name) { export function useSendWallets () { const { wallets } = useWallets() - // return the first enabled wallet that is available and can send + // return all enabled wallets that are available and can send return wallets .filter(w => !w.def.isAvailable || w.def.isAvailable()) .filter(w => w.config?.enabled && canSend(w)) diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js index f7e51356a..cb9edf84d 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/lightning-address/server.js @@ -1,18 +1,20 @@ +import { fetchWithTimeout } from '@/lib/fetch' 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 }) => { - return await createInvoice({ msats: 1000 }, { address }) +export const testCreateInvoice = async ({ address }, { signal }) => { + return await createInvoice({ msats: 1000 }, { address }, { signal }) } export const createInvoice = async ( { msats, description }, - { address } + { address }, + { signal } ) => { - const { callback, commentAllowed } = await lnAddrOptions(address) + const { callback, commentAllowed } = await lnAddrOptions(address, { signal }) const callbackUrl = new URL(callback) // most lnurl providers suck nards so we have to floor to nearest sat @@ -25,7 +27,7 @@ export const createInvoice = async ( } // call callback with amount and conditionally comment - const res = await fetch(callbackUrl.toString()) + const res = await fetchWithTimeout(callbackUrl.toString(), { signal }) assertResponseOk(res) assertContentTypeJson(res) diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js index 61abe48d9..a850a0e63 100644 --- a/wallets/lnbits/client.js +++ b/wallets/lnbits/client.js @@ -1,22 +1,23 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson } from '@/lib/url' export * from '@/wallets/lnbits' -export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) { +export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) { logger.info('trying to fetch wallet') url = url.replace(/\/+$/, '') - await getWallet({ url, adminKey, invoiceKey }) + await getWallet({ url, adminKey, invoiceKey }, { signal }) logger.ok('wallet found') } -export async function sendPayment (bolt11, { url, adminKey }) { +export async function sendPayment (bolt11, { url, adminKey }, { signal }) { url = url.replace(/\/+$/, '') - const response = await postPayment(bolt11, { url, adminKey }) + const response = await postPayment(bolt11, { url, adminKey }, { signal }) - const checkResponse = await getPayment(response.payment_hash, { url, adminKey }) + const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal }) if (!checkResponse.preimage) { throw new Error('No preimage') } @@ -24,7 +25,7 @@ export async function sendPayment (bolt11, { url, adminKey }) { return checkResponse.preimage } -async function getWallet ({ url, adminKey, invoiceKey }) { +async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { const path = '/api/v1/wallet' const headers = new Headers() @@ -32,7 +33,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) { headers.append('Content-Type', 'application/json') headers.append('X-Api-Key', adminKey || invoiceKey) - const res = await fetch(url + path, { method: 'GET', headers }) + const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal }) assertContentTypeJson(res) if (!res.ok) { @@ -44,7 +45,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) { return wallet } -async function postPayment (bolt11, { url, adminKey }) { +async function postPayment (bolt11, { url, adminKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() @@ -54,7 +55,7 @@ async function postPayment (bolt11, { url, adminKey }) { const body = JSON.stringify({ bolt11, out: true }) - const res = await fetch(url + path, { method: 'POST', headers, body }) + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, body, signal }) assertContentTypeJson(res) if (!res.ok) { @@ -66,7 +67,7 @@ async function postPayment (bolt11, { url, adminKey }) { return payment } -async function getPayment (paymentHash, { url, adminKey }) { +async function getPayment (paymentHash, { url, adminKey }, { signal }) { const path = `/api/v1/payments/${paymentHash}` const headers = new Headers() @@ -74,7 +75,7 @@ async function getPayment (paymentHash, { url, adminKey }) { headers.append('Content-Type', 'application/json') headers.append('X-Api-Key', adminKey) - const res = await fetch(url + path, { method: 'GET', headers }) + const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal }) assertContentTypeJson(res) if (!res.ok) { diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js index 72099055b..5bc728265 100644 --- a/wallets/lnbits/server.js +++ b/wallets/lnbits/server.js @@ -1,3 +1,5 @@ +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { FetchTimeoutError } from '@/lib/fetch' import { msatsToSats } from '@/lib/format' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson } from '@/lib/url' @@ -5,13 +7,14 @@ import fetch from 'cross-fetch' export * from '@/wallets/lnbits' -export async function testCreateInvoice ({ url, invoiceKey }) { - return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }) +export async function testCreateInvoice ({ url, invoiceKey }, { signal }) { + return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal }) } export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, invoiceKey }) { + { url, invoiceKey }, + { signal }) { const path = '/api/v1/payments' const headers = new Headers() @@ -38,12 +41,23 @@ export async function createInvoice ( hostname = 'lnbits:5000' } - const res = await fetch(`${agent.protocol}//${hostname}${path}`, { - method: 'POST', - headers, - agent, - body - }) + let res + try { + res = await fetch(`${agent.protocol}//${hostname}${path}`, { + method: 'POST', + headers, + agent, + body, + signal + }) + } catch (err) { + if (err.name === 'AbortError') { + // XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually. + // see https://github.com/node-fetch/node-fetch/issues/1462 + throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + throw err + } assertContentTypeJson(res) if (!res.ok) { diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index b7dc13fad..f6fc3829a 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,21 +1,14 @@ -import { nwcCall, supportedMethods } from '@/wallets/nwc' +import { supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' -export async function testSendPayment ({ nwcUrl }, { logger }) { - const timeout = 15_000 - - const supported = await supportedMethods(nwcUrl, { logger, timeout }) +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 }, { logger }) { - const result = await nwcCall({ - nwcUrl, - method: 'pay_invoice', - params: { invoice: bolt11 } - }, - { logger }) +export async function sendPayment (bolt11, { nwcUrl }, { signal }) { + const result = await nwcTryRun(nwc => nwc.payInvoice(bolt11), { nwcUrl }, { signal }) return result.preimage } diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 5bab23009..3d0e212cf 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,7 +1,10 @@ -import { Relay } from '@/lib/nostr' -import { parseNwcUrl } from '@/lib/url' +import Nostr from '@/lib/nostr' import { string } from '@/lib/yup' -import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools' +import { parseNwcUrl } from '@/lib/url' +import { NDKNwc } from '@nostr-dev-kit/ndk' +import { TimeoutError } from '@/lib/time' + +const NWC_CONNECT_TIMEOUT_MS = 15_000 export const name = 'nwc' export const walletType = 'NWC' @@ -33,61 +36,61 @@ export const card = { subtitle: 'use Nostr Wallet Connect for payments' } -export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) { - const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) - - const relay = await Relay.connect(relayUrl, { timeout }) - logger?.ok(`connected to ${relayUrl}`) - +async function getNwc (nwcUrl, { signal }) { + const ndk = new Nostr().ndk + const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl) + const nwc = new NDKNwc({ + ndk, + pubkey: walletPubkey, + relayUrls, + secret + }) + + // TODO: support AbortSignal try { - const payload = { method, params } - const encrypted = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) - - const request = finalizeEvent({ - kind: 23194, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', walletPubkey]], - content: encrypted - }, secret) - - // we need to subscribe to the response before publishing the request - // since NWC events are ephemeral (20000 <= kind < 30000) - const subscription = relay.fetch([{ - kinds: [23195], - authors: [walletPubkey], - '#e': [request.id] - }], { timeout }) - - await relay.publish(request, { timeout }) - - logger?.info(`published ${method} request`) - - logger?.info(`waiting for ${method} response ...`) - - const [response] = await subscription - - if (!response) { - throw new Error(`no ${method} response`) + await nwc.blockUntilReady(NWC_CONNECT_TIMEOUT_MS) + } catch (err) { + if (err.message === 'Timeout') { + throw new TimeoutError(NWC_CONNECT_TIMEOUT_MS) } + throw err + } - logger?.ok(`${method} response received`) - - if (!verifyEvent(response)) throw new Error(`invalid ${method} response: failed to verify`) - - const decrypted = await nip04.decrypt(secret, walletPubkey, response.content) - const content = JSON.parse(decrypted) - - if (content.error) throw new Error(content.error.message) - if (content.result) return content.result + return nwc +} - throw new Error(`invalid ${method} response: missing error or result`) +/** + * 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 }) { + let nwc + try { + nwc = await getNwc(nwcUrl, { signal }) + const { error, result } = await fun(nwc) + if (error) throw new Error(error.message || error.code) + return result + } catch (e) { + if (e.error) throw new Error(e.error.message || e.error.code) + throw e } finally { - relay?.close() - logger?.info(`closed connection to ${relayUrl}`) + if (nwc) close(nwc) + } +} + +/** + * Close all relay connections of the NDKNwc instance + * @param {NDKNwc} nwc + */ +async function close (nwc) { + for (const relay of nwc.relaySet.relays) { + nwc.ndk.pool.removeRelay(relay.url) } } -export async function supportedMethods (nwcUrl, { logger, timeout } = {}) { - const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout }) +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 index 9a8b06e12..6fb4c82c7 100644 --- a/wallets/nwc/server.js +++ b/wallets/nwc/server.js @@ -1,11 +1,8 @@ -import { withTimeout } from '@/lib/time' -import { nwcCall, supportedMethods } from '@/wallets/nwc' +import { supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' -export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { - const timeout = 15_000 - - const supported = await supportedMethods(nwcUrlRecv, { logger, timeout }) +export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) { + const supported = await supportedMethods(nwcUrlRecv, { signal }) const supports = (method) => supported.includes(method) @@ -20,20 +17,13 @@ export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { } } - return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { logger }), timeout) + return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal }) } -export async function createInvoice ( - { msats, description, expiry }, - { nwcUrlRecv }, { logger }) { - const result = await nwcCall({ - nwcUrl: nwcUrlRecv, - method: 'make_invoice', - params: { - amount: msats, - description, - expiry - } - }, { logger }) +export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { signal }) { + const result = await nwcTryRun( + nwc => nwc.sendReq('make_invoice', { amount: msats, description, expiry }), + { nwcUrl: nwcUrlRecv }, { signal } + ) return result.invoice } diff --git a/wallets/payment.js b/wallets/payment.js index 157e06ead..043d57f89 100644 --- a/wallets/payment.js +++ b/wallets/payment.js @@ -2,17 +2,19 @@ import { useCallback } from 'react' import { useSendWallets } from '@/wallets' import { formatSats } from '@/lib/format' import useInvoice from '@/components/use-invoice' -import { FAST_POLL_INTERVAL } from '@/lib/constants' +import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, - WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError + WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError } from '@/wallets/errors' import { canSend } from './common' import { useWalletLoggerFactory } from './logger' +import { timeoutSignal, withTimeout } from '@/lib/time' export function useWalletPayment () { const wallets = useSendWallets() const sendPayment = useSendPayment() + const loggerFactory = useWalletLoggerFactory() const invoiceHelper = useInvoice() return useCallback(async (invoice, { waitFor, updateOnFallback }) => { @@ -24,44 +26,71 @@ export function useWalletPayment () { throw new WalletsNotAvailableError() } - for (const [i, wallet] of wallets.entries()) { + for (let i = 0; i < wallets.length; i++) { + const wallet = wallets[i] + const logger = loggerFactory(wallet) + + const { bolt11 } = latestInvoice const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice) + + const walletPromise = sendPayment(wallet, logger, latestInvoice) + 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. // that's why we separately check if we received the payment with the invoice controller. - sendPayment(wallet, latestInvoice).catch(reject) - controller.wait(waitFor) - .then(resolve) - .catch(reject) + walletPromise.catch(reject) + pollPromise.then(resolve).catch(reject) }) } catch (err) { - // cancel invoice to make sure it cannot be paid later and create new invoice to retry. - // we only need to do this if payment was attempted which is not the case if the wallet is not enabled. - if (err instanceof WalletPaymentError) { - await invoiceHelper.cancel(latestInvoice) + let paymentError = err + const message = `payment failed: ${paymentError.reason ?? paymentError.message}` + + 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 }) + throw paymentError + } + + // at this point, paymentError is always a wallet error, + // we just need to distinguish between receiver and sender errors - // is there another wallet to try? - const lastAttempt = i === wallets.length - 1 - if (!lastAttempt) { - latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) + try { + // we need to poll one more time to check for failed forwards since sender wallet errors + // can be caused by them which we want to handle as receiver errors, not sender errors. + await invoiceHelper.isInvoice(latestInvoice, waitFor) + } catch (err) { + if (err instanceof WalletError) { + paymentError = err } } - // TODO: receiver fallbacks - // - // if payment failed because of the receiver, we should use the same wallet again. - // if (err instanceof ReceiverError) { ... } + 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 }) + i -= 1 + } else if (paymentError instanceof WalletPaymentError) { + // only log payment errors, not configuration errors + logger.error(message, { bolt11 }) + } + + if (paymentError instanceof WalletPaymentError) { + // if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry. + await invoiceHelper.cancel(latestInvoice) + } - // try next wallet if the payment failed because of the wallet - // and not because it expired or was canceled - if (err instanceof WalletError) { - aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice) - continue + // only create a new invoice if we will try to pay with a wallet again + const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1 + if (retry) { + latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) } - // payment failed not because of the sender or receiver wallet. bail out of attemping wallets. - throw err + aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice) + + continue } finally { controller.stop() } @@ -111,11 +140,7 @@ function invoiceController (inv, isInvoice) { } function useSendPayment () { - const factory = useWalletLoggerFactory() - - return useCallback(async (wallet, invoice) => { - const logger = factory(wallet) - + return useCallback(async (wallet, logger, invoice) => { if (!wallet.config.enabled) { throw new WalletNotEnabledError(wallet.def.name) } @@ -128,12 +153,17 @@ function useSendPayment () { logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 }) try { - const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger }) + const preimage = await withTimeout( + wallet.def.sendPayment(bolt11, wallet.config, { + logger, + signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) + }), + WALLET_SEND_PAYMENT_TIMEOUT_MS) logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) } catch (err) { + // we don't log the error here since we want to handle receiver errors separately const message = err.message || err.toString?.() - logger.error(`payment failed: ${message}`, { bolt11 }) throw new WalletSenderError(wallet.def.name, invoice, message) } - }, [factory]) + }, []) } diff --git a/wallets/phoenixd/client.js b/wallets/phoenixd/client.js index 703ef8dfe..f923aaf4a 100644 --- a/wallets/phoenixd/client.js +++ b/wallets/phoenixd/client.js @@ -1,8 +1,9 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export * from '@/wallets/phoenixd' -export async function testSendPayment (config, { logger }) { +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 @@ -10,7 +11,7 @@ export async function testSendPayment (config, { logger }) { } -export async function sendPayment (bolt11, { url, primaryPassword }) { +export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) { // https://phoenix.acinq.co/server/api#pay-bolt11-invoice const path = '/payinvoice' @@ -21,10 +22,11 @@ export async function sendPayment (bolt11, { url, primaryPassword }) { const body = new URLSearchParams() body.append('invoice', bolt11) - const res = await fetch(url + path, { + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, - body + body, + signal }) assertResponseOk(res) diff --git a/wallets/phoenixd/server.js b/wallets/phoenixd/server.js index 67f324d22..aceefd075 100644 --- a/wallets/phoenixd/server.js +++ b/wallets/phoenixd/server.js @@ -1,17 +1,20 @@ +import { fetchWithTimeout } from '@/lib/fetch' import { msatsToSats } from '@/lib/format' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' export * from '@/wallets/phoenixd' -export async function testCreateInvoice ({ url, secondaryPassword }) { +export async function testCreateInvoice ({ url, secondaryPassword }, { signal }) { return await createInvoice( { msats: 1000, description: 'SN test invoice', expiry: 1 }, - { url, secondaryPassword }) + { url, secondaryPassword }, + { signal }) } export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, secondaryPassword } + { url, secondaryPassword }, + { signal } ) { // https://phoenix.acinq.co/server/api#create-bolt11-invoice const path = '/createinvoice' @@ -24,10 +27,11 @@ export async function createInvoice ( body.append('description', description) body.append('amountSat', msatsToSats(msats)) - const res = await fetch(url + path, { + const res = await fetchWithTimeout(url + path, { method: 'POST', headers, - body + body, + signal }) assertResponseOk(res) diff --git a/wallets/server.js b/wallets/server.js index c329d7670..f14e9fb36 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -15,8 +15,8 @@ import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' -import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' -import { withTimeout } from '@/lib/time' +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' @@ -24,9 +24,9 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] const MAX_PENDING_INVOICES_PER_WALLET = 25 -export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { +export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { predecessorId, models }) { // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { models }) + const wallets = await getInvoiceableWallets(userId, { predecessorId, models }) msats = toPositiveNumber(msats) @@ -81,7 +81,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa export async function createWrappedInvoice (userId, { msats, feePercent, description, descriptionHash, expiry = 360 }, - { models, me, lnd }) { + { predecessorId, models, me, lnd }) { let logger, bolt11 try { const { invoice, wallet } = await createInvoice(userId, { @@ -90,7 +90,7 @@ export async function createWrappedInvoice (userId, description, descriptionHash, expiry - }, { models }) + }, { predecessorId, models }) logger = walletLogger({ wallet, models }) bolt11 = invoice @@ -110,18 +110,48 @@ export async function createWrappedInvoice (userId, } } -export async function getInvoiceableWallets (userId, { models }) { - const wallets = await models.wallet.findMany({ - where: { userId, enabled: true }, - include: { - user: true - }, - orderBy: [ - { priority: 'asc' }, - // use id as tie breaker (older wallet first) - { id: 'asc' } - ] - }) +export async function getInvoiceableWallets (userId, { predecessorId, models }) { + // filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices. + // 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` + SELECT + "Wallet".*, + jsonb_build_object( + 'id', "users"."id", + 'hideInvoiceDesc', "users"."hideInvoiceDesc" + ) AS "user" + FROM "Wallet" + JOIN "users" ON "users"."id" = "Wallet"."userId" + WHERE + "Wallet"."userId" = ${userId} + AND "Wallet"."enabled" = true + AND "Wallet"."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 + -- until there is no more previous invoice + SELECT "Invoice"."id", "Invoice"."predecessorId" + FROM "Invoice" + JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId" + WHERE "Invoice"."actionState" = 'RETRYING' + ) + SELECT + "InvoiceForward"."walletId" + 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) @@ -171,6 +201,9 @@ async function walletCreateInvoice ({ wallet, def }, { expiry }, wallet.wallet, - { logger } - ), 10_000) + { + logger, + signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) + } + ), WALLET_CREATE_INVOICE_TIMEOUT_MS) } diff --git a/worker/earn.js b/worker/earn.js index 630342597..019cf9bb1 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -1,6 +1,5 @@ import { notifyEarner } from '@/lib/webPush' import createPrisma from '@/lib/create-prisma' -import { proportions } from '@/lib/madness' import { SN_NO_REWARDS_IDS } from '@/lib/constants' const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000 @@ -40,18 +39,19 @@ export async function earn ({ name }) { /* How earnings (used to) work: - 1/3: top 21% posts over last 36 hours, scored on a relative basis - 1/3: top 21% comments over last 36 hours, scored on a relative basis + 1/3: top 50% posts over last 36 hours, scored on a relative basis + 1/3: top 50% comments over last 36 hours, scored on a relative basis 1/3: top upvoters of top posts/comments, scored on: - their trust - how much they tipped - how early they upvoted it - how the post/comment scored - Now: 80% of earnings go to top 100 stackers by value, and 10% each to their forever and one day referrers + Now: 80% of earnings go to top stackers by relative value, and 10% each to their forever and one day referrers */ // get earners { userId, id, type, rank, proportion, foreverReferrerId, oneDayReferrerId } + // has to earn at least 125000 msats to be eligible (so that they get at least 1 sat after referrals) const earners = await models.$queryRaw` WITH earners AS ( SELECT users.id AS "userId", users."referrerId" AS "foreverReferrerId", @@ -63,8 +63,8 @@ export async function earn ({ name }) { 'day') uv JOIN users ON users.id = uv.id WHERE NOT (users.id = ANY (${SN_NO_REWARDS_IDS})) + AND uv.proportion >= 0.0000125 ORDER BY proportion DESC - LIMIT 100 ) SELECT earners.*, COALESCE( @@ -86,10 +86,10 @@ export async function earn ({ name }) { let total = 0 const notifications = {} - for (const [i, earner] of earners.entries()) { + for (const [, earner] of earners.entries()) { const foreverReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings let oneDayReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings - const earnerEarnings = Math.floor(parseFloat(proportions[i] * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings + const earnerEarnings = Math.floor(parseFloat(earner.proportion * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings total += earnerEarnings + foreverReferrerEarnings + oneDayReferrerEarnings if (total > sum) { @@ -108,7 +108,7 @@ export async function earn ({ name }) { 'oneDayReferrer', earner.oneDayReferrerId, 'oneDayReferrerEarnings', oneDayReferrerEarnings) - if (earnerEarnings > 0) { + if (earnerEarnings > 1000) { stmts.push(...earnStmts({ msats: earnerEarnings, userId: earner.userId, @@ -140,7 +140,7 @@ export async function earn ({ name }) { } } - if (earner.foreverReferrerId && foreverReferrerEarnings > 0) { + if (earner.foreverReferrerId && foreverReferrerEarnings > 1000) { stmts.push(...earnStmts({ msats: foreverReferrerEarnings, userId: earner.foreverReferrerId, @@ -153,7 +153,7 @@ export async function earn ({ name }) { oneDayReferrerEarnings += foreverReferrerEarnings } - if (earner.oneDayReferrerId && oneDayReferrerEarnings > 0) { + if (earner.oneDayReferrerId && oneDayReferrerEarnings > 1000) { stmts.push(...earnStmts({ msats: oneDayReferrerEarnings, userId: earner.oneDayReferrerId, diff --git a/worker/index.js b/worker/index.js index 5543e289c..16d48c59c 100644 --- a/worker/index.js +++ b/worker/index.js @@ -38,6 +38,12 @@ import { expireBoost } from './expireBoost' import { payingActionConfirmed, payingActionFailed } from './payingAction' import { autoDropBolt11s } from './autoDropBolt11' +// WebSocket polyfill +import ws from 'isomorphic-ws' +if (typeof WebSocket === 'undefined') { + global.WebSocket = ws +} + async function work () { const boss = new PgBoss(process.env.DATABASE_URL) const models = createPrisma({ diff --git a/worker/nostr.js b/worker/nostr.js index 7dd932c95..fe2201262 100644 --- a/worker/nostr.js +++ b/worker/nostr.js @@ -1,5 +1,4 @@ -import { signId, calculateId, getPublicKey } from 'nostr' -import { Relay } from '@/lib/nostr' +import Nostr from '@/lib/nostr' const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } @@ -40,26 +39,18 @@ export async function nip57 ({ data: { hash }, boss, lnd, models }) { const e = { kind: 9735, - pubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY), created_at: Math.floor(new Date(inv.confirmedAt).getTime() / 1000), content: '', tags } - e.id = await calculateId(e) - e.sig = await signId(process.env.NOSTR_PRIVATE_KEY, e.id) console.log('zap note', e, relays) - await Promise.allSettled( - relays.map(async r => { - const timeout = 1000 - const relay = await Relay.connect(r, { timeout }) - try { - await relay.publish(e, { timeout }) - } finally { - relay.close() - } - }) - ) + const signer = Nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY }) + await Nostr.publish(e, { + relays, + signer, + timeout: 1000 + }) } catch (e) { console.log(e) } diff --git a/worker/paidAction.js b/worker/paidAction.js index 70396ddbf..9b3ecb5a6 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -1,7 +1,7 @@ import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd' import { paidActions } from '@/api/paidAction' import { walletLogger } from '@/api/resolvers/wallet' -import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' +import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' import { datePivot } from '@/lib/time' import { Prisma } from '@prisma/client' @@ -270,6 +270,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode request: bolt11, max_fee_mtokens: String(maxFeeMsats), pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS, + confidence: LND_PATHFINDING_TIME_PREF_PPM, max_timeout_height: maxTimeoutHeight }).catch(console.error) } @@ -316,13 +317,11 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a }, { models, lnd, boss }) if (transitionedInvoice) { - const { bolt11, msatsPaid, msatsFeePaid } = transitionedInvoice.invoiceForward.withdrawl - // the amount we paid includes the fee so we need to subtract it to get the amount received - const received = Number(msatsPaid) - Number(msatsFeePaid) + const { bolt11, msatsPaid } = transitionedInvoice.invoiceForward.withdrawl const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) logger.ok( - `↙ payment received: ${formatSats(msatsToSats(received))}`, + `↙ payment received: ${formatSats(msatsToSats(Number(msatsPaid)))}`, { bolt11, preimage: transitionedInvoice.preimage