Compare commits

..

40 Commits

Author SHA1 Message Date
Keyan
6d4dfddae8
improve rewards (#1731)
* don't bias to early zapping so much

* untested rewards/leaderboard changes

* fix cln dep for payments

* make zap proportion scale using quad root

* fix for missing proportion on hidden users

* improve rewards cutoff criteria

* Update api/resolvers/user.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/typeDefs/user.js

Co-authored-by: ekzyis <ek@stacker.news>

* improve switch readability

* small increase in min zap

* refresh materialized views on migration

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-12-18 10:12:11 -06:00
ekzyis
6098d39574
Fix missing logs on save (#1729)
* Fix missing logs on save

* fix receive logs wrt device sync

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-16 17:37:31 -06:00
ekzyis
62a922247d
Add timeouts to all wallet API calls (#1722)
* Add timeout to all wallet API calls

* Pass timeout signal to wallet API

* Fix timeout error message not shown on timeout

* Fix cross-fetch throws generic error message on abort

* Fix wrong method in error message

* Always use FetchTimeoutError

* Catch NDK timeout error to replace with custom timeout error

* Also use 15s for NWC connect timeout

* Add timeout delay
2024-12-16 14:05:31 -06:00
ekzyis
819d382494
Fix lightning address logs deletion (#1728) 2024-12-15 11:14:33 -06:00
ekzyis
14de23b21d
Refactor CLN function signatures (#1726) 2024-12-14 10:32:51 -06:00
ekzyis
3cdfe620d0
Refactor Blink function signatures (#1725)
This makes them consistent with function signatures of other wallets
2024-12-14 08:56:45 -06:00
ekzyis
77d22cfd77
Remove unused wallet context args (#1724) 2024-12-14 08:55:08 -06:00
Riccardo Balbo
bdd24130f9
Nip46 auth with NDK (#1636)
* ndk

* fix: remove duplicated zap note event template

* don't init Nip07 signer by default

* Update wallets/nwc/server.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* nwc protocol parsing workaround

* WebSocket polyfill for worker

* increase nwc timeout

* remove NDKNip46Signer type

* fix type annotation

* move  eslint-disable camelcase to the top

* pass event args to the constructor

* fix error handling

* Update wallets/nwc/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* nip46 auth

* style tweak, remove unmaintained signers from the list

* don't use modal

* workaround url parsing

* use kind 27235

* add kind 27235 metadata

* show suggestion after a timeout

* Update lib/nostr.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update components/nostr-auth.js

Co-authored-by: ekzyis <ek@stacker.news>

* fix unrelated lnauth crash when closing ext prompt

* make ui consistent ...

* give buttons spacing

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-13 20:25:34 -06:00
ekzyis
285203889d Remove unused import 2024-12-13 21:20:51 +01:00
ekzyis
a50a2c8bd1
Fix receiver fallbacks depend on fast polls (#1723) 2024-12-13 14:19:00 -06:00
Riccardo Balbo
d73f6323ff
NDK (#1590)
* ndk

* fix: remove duplicated zap note event template

* don't init Nip07 signer by default

* Update wallets/nwc/server.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* nwc protocol parsing workaround

* WebSocket polyfill for worker

* increase nwc timeout

* remove NDKNip46Signer type

* fix type annotation

* move  eslint-disable camelcase to the top

* pass event args to the constructor

* fix error handling

* Update wallets/nwc/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* Fix type annotation

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-12-13 13:28:36 -06:00
Riccardo Balbo
52734940a3
Bolt12 dev environment (#1702)
* lndk-eclair bolt12 test environment

* use static certs for lndk dev

* move eclair/lndk/cln to wallets profile, force lndk onto x86 platform

* fix port conflict

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-13 12:30:30 -06:00
Keyan
bf541aa643
Merge pull request #1721 from stackernews/fix-missing-usage-of-timeout-error
Fix missing usage of new TimeoutError
2024-12-13 10:29:50 -06:00
ekzyis
06a3a71eb9 Use new TimeoutError 2024-12-13 16:29:00 +01:00
Keyan
adfbdeddba
Merge pull request #1720 from stackernews/fix-toast-padding
Fix missing toast padding
2024-12-13 08:39:45 -06:00
ekzyis
fc4303658d
Use AbortSignal.timeout + custom timeout error message (#1718)
* refactor: replace custom logic with AbortSignal.timeout

* Use custom timeout error message

* Include method and url in fetch timeout error

* Fix error not rethrown
2024-12-13 08:38:42 -06:00
Keyan
e8434d07c5
Merge pull request #1719 from stackernews/fix-missing-save-error-log
Fix missing logging of sender wallet validation error
2024-12-13 08:36:37 -06:00
ekzyis
959cd1f3f4 Fix missing toast padding 2024-12-13 15:03:04 +01:00
ekzyis
64ba7a56cb Fix missing wallet error logging 2024-12-13 11:30:54 +01:00
Riccardo Balbo
ec213907fc
Use debian cdn instead of de mirror (#1715)
* Use debian cdn instead of de mirror

* use https in deb mirror
2024-12-12 10:30:39 -06:00
ekzyis
66b7352bf0
Fix forever edits (#1716)
* Fix forever edits

* Refactor edit check on server
2024-12-12 09:35:30 -06:00
Keyan
6918bcb452
Merge pull request #1714 from stackernews/1704-fix-exposed-routing-fees
Don't expose p2p zap receivers to routing fees
2024-12-11 15:47:57 -06:00
k00b
98fae6c9ae prioritize payment reliability 2024-12-11 15:38:38 -06:00
Keyan
0e765d4179
Merge pull request #1709 from stackernews/fix-client-pending-forwards-paid
Fix pending forwards considered paid by client
2024-12-11 14:43:33 -06:00
Keyan
3fe5f4b435
Merge pull request #1713 from stackernews/shared-failed-forwards-view 2024-12-11 14:42:31 -06:00
ekzyis
c9439c33c6 Don't expose p2p zap receivers to routing fees 2024-12-11 20:55:43 +01:00
ekzyis
8f092cdc66 Use consistent view about failed forwards 2024-12-11 20:09:49 +01:00
ekzyis
4e6fb40c0b Use conditional waitFor to fix premature payment success 2024-12-11 19:27:29 +01:00
ekzyis
8cb89574ae Fix pending forwards considered paid by client 2024-12-11 14:39:01 +01:00
ekzyis
756e75ed7c
Fix latest timestamp not updated (#1705) 2024-12-10 15:36:04 -06:00
ekzyis
a46f81f1e1
Receiver fallbacks (#1688)
* Use same naming scheme between ln containers and env vars

* Add router_lnd container

* Only open channels to router_lnd

* Use 1sat base fee and 0ppm fee rate

* Add script to test routing

* Also fund router_lnd wallet

* Receiver fallbacks

* Rename to predecessorId

* Remove useless wallet table join

* Missing renaming to predecessor

* Fix payment stuck on sender error

We want to await the invoice poll promise so we can check for receiver errors, but in case of sender errors, the promise will never settle.

* Don't log failed forwards as sender errors

* fix check for receiver error

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-10 14:15:29 -06:00
Keyan
e6c74c965b
Update awards.csv 2024-12-10 11:15:10 -06:00
ekzyis
3ead4db8dc
Use new LND node as routing node (#1700)
* Use same naming scheme between ln containers and env vars

* Add router_lnd container

* Only open channels to router_lnd

* Use 1sat base fee and 0ppm fee rate

* Add script to test routing

* Also fund router_lnd wallet

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-12-10 10:52:17 -06:00
ekzyis
1a41760915
CLN: Use port 9735 and log to stdout (#1701)
* use port 9735 for cln node

this makes it consistent with the rest of the environment (default port for regtest is 19846)

* cln log to stdout

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
2024-12-10 09:14:18 -06:00
ekzyis
3e29e04b01
Use same naming scheme between ln containers and env vars (#1698) 2024-12-10 09:13:14 -06:00
k00b
bf20cf8f56 fix different carousels named the exact same thing 2024-12-09 19:06:46 -06:00
k00b
52098a3e50 fix broken static header from carousel 2024-12-09 19:03:30 -06:00
ekzyis
61fb1c445f
Fix header carousel desync (#1696) 2024-12-09 16:09:26 -06:00
Keyan
d05a27a6c3
Update awards.csv 2024-12-09 09:10:49 -06:00
ekzyis
a5fa40aa1b
Fix comment in useSendWallets (#1691) 2024-12-09 09:04:41 -06:00
81 changed files with 1939 additions and 865 deletions

View File

@ -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
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
# stacker lnd container stuff
STACKER_LND_REST_PORT=8081
STACKER_LND_GRPC_PORT=10010
# 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

5
.gitignore vendored
View File

@ -58,4 +58,7 @@ docker-compose.*.yml
scripts/nwc-keys.json
# lnbits
docker/lnbits/data
docker/lnbits/data
# lndk
!docker/lndk/tls-*.pem

View File

@ -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

View File

@ -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

View File

@ -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,11 @@ 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 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) || myBio || isJob(item)
if (!canEdit) {
throw new GqlInputError('item can no longer be edited')
}

View File

@ -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) {

View File

@ -66,11 +66,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,

View File

@ -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

View File

@ -59,6 +59,11 @@ export default gql`
photoId: Int
since: Int
"""
this is only returned when we sort stackers by value
"""
proportion: Float
optional: UserOptional!
privates: UserPrivates

View File

@ -129,6 +129,7 @@ const typeDefs = `
item: Item
itemAct: ItemAct
forwardedSats: Int
forwardStatus: String
}
type Withdrawl {

View File

@ -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

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
152 aegroto pr #1589 #1586 easy 100k aegroto@blink.sv 2024-12-07
153 aegroto issue #1589 #1586 easy 10k aegroto@blink.sv 2024-12-07
154 aegroto pr #1619 #914 easy 100k aegroto@blink.sv 2024-12-07
155 felipebueno pr #1620 medium 1 225k felipebueno@getalby.com ??? 2024-12-09
156 Soxasora pr #1647 #1645 easy 100k soxasora@blink.sv 2024-12-07
157 Soxasora pr #1667 #1568 easy 100k soxasora@blink.sv 2024-12-07
158 aegroto pr #1633 #1471 easy 1 90k aegroto@blink.sv 2024-12-07
159 Darth-Coin issue #1649 #1421 medium 25k darthcoin@stacker.news 2024-12-07
160 Soxasora pr #1685 medium 250k soxasora@blink.sv 2024-12-07
161 aegroto pr #1606 #1242 medium 250k aegroto@blink.sv 2024-12-07
162 sfr0xyz issue #1696 #1196 good-first-issue 2k sefiro@getalby.com 2024-12-10

View File

@ -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
USER pptruser

View File

@ -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)

View File

@ -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

View File

@ -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 (
<>
<PriceCarouselProvider>
<DesktopHeader {...props} />
<MobileHeader {...props} />
<StickyBar {...props} />
</>
</PriceCarouselProvider>
)
}

View File

@ -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 (
<PriceCarouselContext.Provider value={[selection, handleClick]}>
{children}
</PriceCarouselContext.Provider>
)
}
export function usePriceCarousel () {
return useContext(PriceCarouselContext)
}

View File

@ -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 (
<Container as='header' className='px-sm-0'>
<Navbar>
<Nav
className={styles.navbarNav}
>
<BackOrBrand />
<SearchItem />
<NavPrice className='justify-content-end' />
</Nav>
</Navbar>
</Container>
<PriceCarouselProvider>
<Container as='header' className='px-sm-0'>
<Navbar>
<Nav
className={styles.navbarNav}
>
<BackOrBrand />
<SearchItem />
<NavPrice className='justify-content-end' />
</Nav>
</Navbar>
</Container>
</PriceCarouselProvider>
)
}

View File

@ -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 (
<>
<h4 className='fw-bold text-danger pb-1'>error: {message}</h4>
<div className='text-muted pb-4'>{details}</div>
</>
)
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 (
<>
<ExtensionError message='nostr extension not found' details='Nostr extensions are the safest way to use your nostr identity on Stacker News.' />
<Row className='w-100 text-muted'>
<AccordianItem
header={`Which extensions can I use to ${(text || 'Login').toLowerCase()} with Nostr?`}
show
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://getalby.com'>Alby</a><br />
available for: chrome, firefox, and safari
</li>
<li>
<a href='https://www.getflamingo.org/'>Flamingo</a><br />
available for: chrome
</li>
<li>
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a><br />
available for: chrome
</li>
<li>
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a><br />
available for: firefox
</li>
<li>
<a href='https://github.com/fiatjaf/horse'>horse</a><br />
available for: chrome<br />
supports hardware signing
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Row>
<h4 className='fw-bold text-danger pb-1'>error</h4>
<div className='text-muted pb-4'>{message}</div>
</>
)
}
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
return () => {
clearSuggestionTimer()
}
}, [])
console.info('nostr extension detected')
// 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
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
}
const k1 = data?.createAuth.k1
if (!k1) throw new Error('Error generating challenge') // should never happen
// 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 })
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 <div>error</div>
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 && <NostrExplainer text={text} />}
{extensionError && <ExtensionError {...extensionError} />}
{hasExtension && !extensionError &&
<>
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
</>}
{status.error && <NostrError message={status.msg} />}
{status.loading
? (
<>
<div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'>
<Moon className='spin fill-grey flex-shrink-0' width='30' height='30' />
{status.msg}
</div>
{status.button && (
<Button
className='w-100' variant='primary'
onClick={() => status.button.action()}
>
{status.button.label}
</Button>
)}
{suggestion && (
<div className='text-muted text-center small pt-2'>{suggestion}</div>
)}
</>
)
: (
<>
<Form
initial={{ token: '' }}
onSubmit={values => {
if (!values.token) {
setError(new Error('Token or NIP-05 address is required'))
} else {
auth(values.token)
}
}}
>
<Input
label='Connect with token or NIP-05 address'
name='token'
placeholder='bunker://... or NIP-05 address'
required
autoFocus
/>
<div className='mt-2'>
<SubmitButton className='w-100' variant='primary'>
{text || 'Login'} with token or NIP-05
</SubmitButton>
</div>
</Form>
<div className='text-center text-muted fw-bold my-2'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</>
)}
</>
)
}
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
function NostrExplainer ({ text, children }) {
const router = useRouter()
return (
<Container>
<div className={styles.login}>
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
<h3 className='w-100 pb-2'>
{text || 'Login'} with Nostr
</h3>
<Row className='w-100 text-muted'>
<Col className='ps-0 mb-4' md>
<AccordianItem
header='Which NIP-46 signers can I use?'
body={
<>
<Row>
<Col xs>
<ul>
<li>
<a href='https://nsec.app/'>Nsec.app</a>
<ul>
<li>available for: chrome, firefox, and safari</li>
</ul>
</li>
<li>
<a href='https://app.nsecbunker.com/'>nsecBunker</a>
<ul>
<li>available as: SaaS or self-hosted</li>
</ul>
</li>
</ul>
</Col>
</Row>
</>
}
/>
<AccordianItem
header='Which extensions can I use?'
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://getalby.com'>Alby</a>
<ul>
<li>available for: chrome, firefox, and safari</li>
</ul>
</li>
<li>
<a href='https://www.getflamingo.org/'>Flamingo</a>
<ul>
<li>available for: chrome</li>
</ul>
</li>
<li>
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a>
<ul>
<li>available for: chrome</li>
</ul>
</li>
<li>
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a>
<ul>
<li>available for: firefox</li>
</ul>
</li>
<li>
<a href='https://github.com/fiatjaf/horse'>horse</a>
<ul>
<li>available for: chrome</li>
<li>supports hardware signing</li>
</ul>
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Col>
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
{children}
</Col>
</Row>
</div>
</Container>
)
}
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return (
<NostrExplainer text={text}>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</NostrExplainer>
)
}

View File

@ -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 (
<div className='fw-bold text-info'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
<Check className='fill-info me-1' />
{numWithUnits(amount, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
{actionString}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{(n.withdrawl.forwardedActionType === 'ZAP' && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||

View File

@ -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 (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -88,7 +62,7 @@ export default function Price ({ className }) {
)
}
if (asSats === '1btc') {
if (selection === '1btc') {
return (
<div className={compClassName} onClick={handleClick} variant='link'>
1sat=1sat
@ -96,7 +70,7 @@ export default function Price ({ className }) {
)
}
if (asSats === 'blockHeight') {
if (selection === 'blockHeight') {
if (blockHeight <= 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -105,7 +79,7 @@ export default function Price ({ className }) {
)
}
if (asSats === 'halving') {
if (selection === 'halving') {
if (!halving) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -114,7 +88,7 @@ export default function Price ({ className }) {
)
}
if (asSats === 'chainFee') {
if (selection === 'chainFee') {
if (chainFee <= 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -123,7 +97,7 @@ export default function Price ({ className }) {
)
}
if (asSats === 'fiat') {
if (selection === 'fiat') {
if (!price || price < 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>

View File

@ -118,7 +118,7 @@ export const ToastProvider = ({ children }) => {
return (
<ToastContext.Provider value={toaster}>
<ToastContainer className={`pb-3 pe-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
<ToastContainer className={`pb-3 px-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
{visibleToasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
const onClose = () => {

View File

@ -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

View File

@ -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 {

View File

@ -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) }

View File

@ -107,13 +107,13 @@ export function User ({ user, rank, statComps, className = 'mb-2', Embellish, ny
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>}
{Embellish && <Embellish rank={rank} />}
{Embellish && <Embellish rank={rank} user={user} />}
</UserBase>
</>
)
}
function UserHidden ({ rank, Embellish }) {
function UserHidden ({ rank, user, Embellish }) {
return (
<>
{rank
@ -133,7 +133,7 @@ function UserHidden ({ rank, Embellish }) {
<div className={`${styles.title} text-muted d-inline-flex align-items-center`}>
stacker is in hiding
</div>
{Embellish && <Embellish rank={rank} />}
{Embellish && <Embellish rank={rank} user={user} />}
</div>
</div>
</>
@ -148,7 +148,7 @@ export function ListUsers ({ users, rank, statComps = DEFAULT_STAT_COMPONENTS, E
{users.map((user, i) => (
user
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} nymActionDropdown={nymActionDropdown} />
: <UserHidden key={i} rank={rank && i + 1} Embellish={Embellish} />
: <UserHidden key={i} rank={rank && i + 1} user={user} Embellish={Embellish} />
))}
</div>
)

View File

@ -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

View File

@ -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:

68
docker/eclair/Dockerfile Normal file
View File

@ -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}"

View File

@ -0,0 +1 @@
フマthC(涬ト€ロレBワモqFノ<46>`iBチL)L<><4C><EFBFBD>

View File

@ -0,0 +1 @@
6═У1>Т▀ВbgOоЕ╣©░ь}Нk ┴!sb÷²У÷

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBThCj41Abt/iEDYYMXb+mfHJmXN211JGYDjekJOmCbUoAoGCCqGSM49
AwEHoUQDQgAEFPJk3jfBfWyHM7TB2pCJ45J5VqVIr9x4nvBIZPQdvizgV4qqiNnn
KTohZtH7eJ/T/epN3V9UNH3jW5MTcnIv+g==
-----END EC PRIVATE KEY-----

17
docker/lndk/Dockerfile Normal file
View File

@ -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}"

10
docker/lndk/tls-cert.pem Normal file
View File

@ -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-----

5
docker/lndk/tls-key.pem Normal file
View File

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTa/r2pnmB05EwKk6
a4FbigSagGBok+i/ASxkG9iGedWhRANCAARnbvXF1BkjyMnEgm5hm+v+FPrcbxNG
kr87DHPqShYl5Qfj/98f1sWDMZRwdJYfX00FPLXVYidgGP0Jx1cFJDuA
-----END PRIVATE KEY-----

View File

@ -254,7 +254,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)

View File

@ -23,6 +23,7 @@ export const INVOICE_FIELDS = gql`
actionError
confirmedPreimage
forwardedSats
forwardStatus
}`
export const INVOICE_FULL = gql`

View File

@ -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)

View File

@ -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 = 15_000
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 15_000

View File

@ -1,14 +1,28 @@
export async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), timeout)
import { TimeoutError, timeoutSignal } from '@/lib/time'
const response = await fetch(resource, {
...options,
signal: controller.signal
})
clearTimeout(id)
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 {

View File

@ -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

View File

@ -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') {

View File

@ -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,155 +15,149 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://nostr.mutinywallet.com/',
'wss://relay.mutinywallet.com/'
]
export const RELAYS_BLACKLIST = []
export class Relay {
constructor (relayUrl) {
const ws = new WebSocket(relayUrl)
/* eslint-disable camelcase */
ws.onmessage = (msg) => {
const [type, notice] = JSON.parse(msg.data)
if (type === 'NOTICE') {
console.log('relay notice:', notice)
}
}
/**
* @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<NDKNwc>} nwc
* @property {function(Object, {privKey: string, signer: NDKSigner}): Promise<NDKEvent>} sign
* @property {function(Object, {relays: Array<string>, privKey: string, signer: NDKSigner}): Promise<NDKEvent>} publish
*/
export class Nostr {
/**
* @type {NDK}
*/
_ndk = null
ws.onerror = (err) => {
console.error('websocket error:', err.message)
this.error = err.message
}
this.ws = ws
this.url = relayUrl
this.error = 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
})
}
static async connect (url, { timeout } = {}) {
const relay = new Relay(url)
await relay.waitUntilConnected({ timeout })
return relay
/**
* @type {NDK}
*/
get ndk () {
return this._ndk
}
get connected () {
return this.ws.readyState === WebSocket.OPEN
/**
*
* @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
}
get closed () {
return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @returns {Promise<NDKEvent>}
*/
async sign ({ kind, created_at, content, tags }, { signer } = {}) {
const event = new NDKEvent(this.ndk, {
kind,
created_at,
content,
tags
})
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 waitUntilConnected ({ timeout } = {}) {
let interval
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {Array<string>} context.relays
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @param {number} context.timeout
* @returns {Promise<NDKEvent>}
*/
async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) {
const event = await this.sign({ kind, created_at, content, tags }, { signer })
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)
const successfulRelays = []
const failedRelays = []
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)
event.on('relay:publish:failed', (relay, error) => {
failedRelays.push({ relay: relay.url, error })
})
for (const relay of (await relaySet.publish(event, timeout))) {
successfulRelays.push(relay.url)
}
return {
event,
successfulRelays,
failedRelays
}
}
async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) {
try {
return await withTimeout(checkPromise, timeout)
} catch (err) {
this.close()
throw err
} finally {
clearInterval(interval)
}
}
signer ??= this.getSigner({ supportNip07: true })
const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout })
close () {
const state = this.ws.readyState
if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) {
this.ws.close()
}
}
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)
if (type !== 'OK' || eventId !== event.id) return
if (accepted) {
resolve(eventId)
} else {
reject(new Error(reason || `event rejected: ${eventId}`))
}
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(['EVENT', event]))
})
try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
}
}
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)
}
}
}
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 }
}
}
}
/**
* @type {Nostr}
*/
export default new Nostr()
export function hexToBech32 (hex, prefix = 'npub') {
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
}
@ -187,48 +179,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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 })

273
package-lock.json generated
View File

@ -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",
@ -4371,6 +4372,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",
@ -4406,6 +4416,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",
@ -7310,6 +7363,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",
@ -8089,6 +8155,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",
@ -8968,6 +9047,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",
@ -9581,6 +9700,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",
@ -9675,6 +9809,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",
@ -9829,6 +9973,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",
@ -14154,6 +14307,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",
@ -15606,6 +15768,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",
@ -19408,11 +19576,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",
@ -19452,6 +19632,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",
@ -19574,11 +19760,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",
@ -19997,6 +20198,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",
@ -20320,6 +20543,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",
@ -20896,6 +21160,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",

View File

@ -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",

View File

@ -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 <PageLoading />
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 (
<div className='text-muted fst-italic'>

View File

@ -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;

View File

@ -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;

View File

@ -904,39 +904,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")

20
scripts/test-routing.sh Normal file
View File

@ -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

View File

@ -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

View File

@ -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 || []
}

View File

@ -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(apiKeyRecv, mutation, {
input: {
amount: msatsToSats(msats),
expiresIn: Math.floor(expiry / 60) || 1,
memo: description,
walletId: wallet.id
const out = await request({
apiKey,
query: `
mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
lnInvoiceCreate(input: $input) {
invoice {
paymentRequest
}
errors {
message
}
}
}`,
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
}

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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')

View File

@ -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))

View File

@ -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)

View File

@ -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) {

View File

@ -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) {

View File

@ -1,21 +1,16 @@
import { nwcCall, supportedMethods } from '@/wallets/nwc'
import { getNwc, 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 nwc = await getNwc(nwcUrl, { signal })
// TODO: support AbortSignal
const result = await nwcTryRun(() => nwc.payInvoice(bolt11))
return result.preimage
}

View File

@ -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,49 @@ 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}`)
export async function getNwc (nwcUrl, { signal }) {
const ndk = 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`)
return nwc
}
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
throw new Error(`invalid ${method} response: missing error or result`)
} finally {
relay?.close()
logger?.info(`closed connection to ${relayUrl}`)
/**
* 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) {
try {
const { error, result } = await fun()
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
}
}
export async function supportedMethods (nwcUrl, { logger, timeout } = {}) {
const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout })
export async function supportedMethods (nwcUrl, { signal }) {
const nwc = await getNwc(nwcUrl, { signal })
// TODO: support AbortSignal
const result = await nwcTryRun(() => nwc.getInfo())
return result.methods
}

View File

@ -1,11 +1,8 @@
import { withTimeout } from '@/lib/time'
import { nwcCall, supportedMethods } from '@/wallets/nwc'
import { getNwc, 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,12 @@ 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 nwc = await getNwc(nwcUrlRecv, { signal })
// TODO: support AbortSignal
const result = await nwcTryRun(() => nwc.sendReq('make_invoice', { amount: msats, description, expiry }))
return result.invoice
}

View File

@ -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}`
// is there another wallet to try?
const lastAttempt = i === wallets.length - 1
if (!lastAttempt) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
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
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) { ... }
// 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
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 })
}
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
throw err
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)
}
// 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 })
}
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])
}, [])
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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,

View File

@ -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({

View File

@ -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)
}

View File

@ -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