Compare commits
40 Commits
080459cd21
...
6d4dfddae8
Author | SHA1 | Date | |
---|---|---|---|
|
6d4dfddae8 | ||
|
6098d39574 | ||
|
62a922247d | ||
|
819d382494 | ||
|
14de23b21d | ||
|
3cdfe620d0 | ||
|
77d22cfd77 | ||
|
bdd24130f9 | ||
|
285203889d | ||
|
a50a2c8bd1 | ||
|
d73f6323ff | ||
|
52734940a3 | ||
|
bf541aa643 | ||
|
06a3a71eb9 | ||
|
adfbdeddba | ||
|
fc4303658d | ||
|
e8434d07c5 | ||
|
959cd1f3f4 | ||
|
64ba7a56cb | ||
|
ec213907fc | ||
|
66b7352bf0 | ||
|
6918bcb452 | ||
|
98fae6c9ae | ||
|
0e765d4179 | ||
|
3fe5f4b435 | ||
|
c9439c33c6 | ||
|
8f092cdc66 | ||
|
4e6fb40c0b | ||
|
8cb89574ae | ||
|
756e75ed7c | ||
|
a46f81f1e1 | ||
|
e6c74c965b | ||
|
3ead4db8dc | ||
|
1a41760915 | ||
|
3e29e04b01 | ||
|
bf20cf8f56 | ||
|
52098a3e50 | ||
|
61fb1c445f | ||
|
d05a27a6c3 | ||
|
a5fa40aa1b |
@ -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
|
||||
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -59,3 +59,6 @@ scripts/nwc-keys.json
|
||||
|
||||
# lnbits
|
||||
docker/lnbits/data
|
||||
|
||||
# lndk
|
||||
!docker/lndk/tls-*.pem
|
@ -317,23 +317,25 @@ 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) {
|
||||
|
||||
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),
|
||||
@ -341,9 +343,12 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
||||
expiry: INVOICE_EXPIRE_SECS
|
||||
}, retryContext)
|
||||
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
||||
} else {
|
||||
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
||||
} 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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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,13 +876,14 @@ async function upsertWallet (
|
||||
)
|
||||
}
|
||||
|
||||
if (canReceive({ def: walletDef, config: walletData })) {
|
||||
txs.push(
|
||||
models.walletLog.createMany({
|
||||
data: {
|
||||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: 'SUCCESS',
|
||||
message: id ? 'wallet details updated' : 'wallet attached'
|
||||
message: id ? 'details for receiving updated' : 'details for receiving saved'
|
||||
}
|
||||
}),
|
||||
models.walletLog.create({
|
||||
@ -870,10 +891,11 @@ async function upsertWallet (
|
||||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: enabled ? 'SUCCESS' : 'INFO',
|
||||
message: enabled ? 'wallet enabled' : 'wallet disabled'
|
||||
message: enabled ? 'receiving enabled' : 'receiving disabled'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const [upsertedWallet] = await models.$transaction(txs)
|
||||
return upsertedWallet
|
||||
|
@ -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
|
||||
|
||||
|
@ -129,6 +129,7 @@ const typeDefs = `
|
||||
item: Item
|
||||
itemAct: ItemAct
|
||||
forwardedSats: Int
|
||||
forwardStatus: String
|
||||
}
|
||||
|
||||
type Withdrawl {
|
||||
|
@ -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
|
||||
|
|
@ -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
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
46
components/nav/price-carousel.js
Normal file
46
components/nav/price-carousel.js
Normal 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)
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
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 (
|
||||
<PriceCarouselProvider>
|
||||
<Container as='header' className='px-sm-0'>
|
||||
<Navbar>
|
||||
<Nav
|
||||
@ -15,5 +17,6 @@ export default function StaticHeader () {
|
||||
</Nav>
|
||||
</Navbar>
|
||||
</Container>
|
||||
</PriceCarouselProvider>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
useEffect(() => {
|
||||
if (!k1 || !hasExtension) return
|
||||
|
||||
console.info('nostr extension detected')
|
||||
|
||||
let mounted = true;
|
||||
(async function () {
|
||||
try {
|
||||
// have them sign a message with the challenge
|
||||
let event
|
||||
try {
|
||||
event = await callWithTimeout(() => window.nostr.signEvent({
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', k1]],
|
||||
content: 'Stacker News Authentication'
|
||||
}), 5000)
|
||||
if (!event) throw new Error('extension returned empty event')
|
||||
} catch (e) {
|
||||
if (e.message === 'window.nostr call already executing' || !mounted) return
|
||||
setExtensionError({ message: 'nostr extension failed to sign event', details: e.message })
|
||||
return
|
||||
const clearSuggestionTimer = () => {
|
||||
if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current)
|
||||
}
|
||||
|
||||
// sign them in
|
||||
const setSuggestionWithTimer = (msg) => {
|
||||
clearSuggestionTimer()
|
||||
suggestionTimeout.current = setTimeout(() => {
|
||||
setSuggestion(msg)
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSuggestionTimer()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// authorize user
|
||||
const auth = useCallback(async (nip46token) => {
|
||||
setStatus({
|
||||
msg: 'Waiting for authorization',
|
||||
error: false,
|
||||
loading: true
|
||||
})
|
||||
try {
|
||||
const { data, error } = await createAuth()
|
||||
if (error) throw error
|
||||
|
||||
const k1 = data?.createAuth.k1
|
||||
if (!k1) throw new Error('Error generating challenge') // should never happen
|
||||
|
||||
const useExtension = !nip46token
|
||||
const signer = Nostr.getSigner({ nip46token, supportNip07: useExtension })
|
||||
if (!signer && useExtension) throw new Error('No extension found')
|
||||
|
||||
if (signer instanceof NDKNip46Signer) {
|
||||
signer.once('authUrl', challengeResolver)
|
||||
}
|
||||
|
||||
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(event),
|
||||
event: JSON.stringify(signedEvent),
|
||||
callbackUrl,
|
||||
multiAuth
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error('authorization failed', e)
|
||||
setError(e)
|
||||
} finally {
|
||||
clearSuggestionTimer()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return
|
||||
console.log('nostr auth error', e)
|
||||
setExtensionError({ message: `${text} failed`, details: e.message })
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [k1, hasExtension])
|
||||
|
||||
if (error) return <div>error</div>
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasExtension === false && <NostrExplainer text={text} />}
|
||||
{extensionError && <ExtensionError {...extensionError} />}
|
||||
{hasExtension && !extensionError &&
|
||||
{status.error && <NostrError message={status.msg} />}
|
||||
{status.loading
|
||||
? (
|
||||
<>
|
||||
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
|
||||
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
|
||||
</>}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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>) ||
|
||||
|
@ -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'>
|
||||
|
@ -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 = () => {
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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) }
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
68
docker/eclair/Dockerfile
Normal 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}"
|
1
docker/eclair/stacker/channel_seed.dat
Normal file
1
docker/eclair/stacker/channel_seed.dat
Normal file
@ -0,0 +1 @@
|
||||
フマthC(涬トロレBワモqFノ<46>`iBチL)L<><4C><EFBFBD>
|
1
docker/eclair/stacker/node_seed.dat
Normal file
1
docker/eclair/stacker/node_seed.dat
Normal file
@ -0,0 +1 @@
|
||||
6═У1>Т▀ВbgOоЕ╣©░ь}Нk ┴!sb÷²У÷
|
BIN
docker/eclair/stacker/regtest/audit.sqlite
Normal file
BIN
docker/eclair/stacker/regtest/audit.sqlite
Normal file
Binary file not shown.
BIN
docker/eclair/stacker/regtest/eclair.sqlite
Normal file
BIN
docker/eclair/stacker/regtest/eclair.sqlite
Normal file
Binary file not shown.
1
docker/eclair/stacker/regtest/last_jdbcurl
Normal file
1
docker/eclair/stacker/regtest/last_jdbcurl
Normal file
@ -0,0 +1 @@
|
||||
sqlite
|
BIN
docker/eclair/stacker/regtest/network.sqlite
Normal file
BIN
docker/eclair/stacker/regtest/network.sqlite
Normal file
Binary file not shown.
BIN
docker/lnd/router/regtest/admin.macaroon
Normal file
BIN
docker/lnd/router/regtest/admin.macaroon
Normal file
Binary file not shown.
BIN
docker/lnd/router/regtest/macaroons.db
Normal file
BIN
docker/lnd/router/regtest/macaroons.db
Normal file
Binary file not shown.
BIN
docker/lnd/router/regtest/wallet.db
Normal file
BIN
docker/lnd/router/regtest/wallet.db
Normal file
Binary file not shown.
15
docker/lnd/router/tls.cert
Normal file
15
docker/lnd/router/tls.cert
Normal 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-----
|
5
docker/lnd/router/tls.key
Normal file
5
docker/lnd/router/tls.key
Normal 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
17
docker/lndk/Dockerfile
Normal 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
10
docker/lndk/tls-cert.pem
Normal 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
5
docker/lndk/tls-key.pem
Normal file
@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTa/r2pnmB05EwKk6
|
||||
a4FbigSagGBok+i/ASxkG9iGedWhRANCAARnbvXF1BkjyMnEgm5hm+v+FPrcbxNG
|
||||
kr87DHPqShYl5Qfj/98f1sWDMZRwdJYfX00FPLXVYidgGP0Jx1cFJDuA
|
||||
-----END PRIVATE KEY-----
|
@ -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)
|
||||
|
@ -23,6 +23,7 @@ export const INVOICE_FIELDS = gql`
|
||||
actionError
|
||||
confirmedPreimage
|
||||
forwardedSats
|
||||
forwardStatus
|
||||
}`
|
||||
|
||||
export const INVOICE_FULL = gql`
|
||||
|
20
lib/cln.js
20
lib/cln.js
@ -2,12 +2,17 @@ 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, {
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -24,8 +29,17 @@ export const createInvoice = async ({ socket, rune, cert, label, description, ms
|
||||
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)
|
||||
|
@ -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
|
||||
|
30
lib/fetch.js
30
lib/fetch.js
@ -1,14 +1,28 @@
|
||||
export async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), timeout)
|
||||
import { TimeoutError, timeoutSignal } from '@/lib/time'
|
||||
|
||||
const response = await fetch(resource, {
|
||||
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`
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout (resource, { signal, timeout = 1000, ...options } = {}) {
|
||||
try {
|
||||
return await fetch(resource, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
signal: signal ?? timeoutSignal(timeout)
|
||||
})
|
||||
clearTimeout(id)
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') {
|
||||
// use custom error message
|
||||
throw new FetchTimeoutError(options.method ?? 'GET', resource, err.timeout)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
class LRUCache {
|
||||
|
@ -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
|
||||
|
12
lib/lnurl.js
12
lib/lnurl.js
@ -1,6 +1,8 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { bech32 } from 'bech32'
|
||||
import { lnAddrSchema } from './validate'
|
||||
import { FetchTimeoutError } from '@/lib/fetch'
|
||||
import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from './constants'
|
||||
|
||||
export function encodeLNUrl (url) {
|
||||
const words = bech32.toWords(Buffer.from(url.toString(), 'utf8'))
|
||||
@ -25,7 +27,7 @@ export function lnurlPayDescriptionHash (data) {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
export async function lnAddrOptions (addr) {
|
||||
export async function lnAddrOptions (addr, { signal } = {}) {
|
||||
await lnAddrSchema().fields.addr.validate(addr)
|
||||
const [name, domain] = addr.split('@')
|
||||
let protocol = 'https'
|
||||
@ -35,12 +37,16 @@ export async function lnAddrOptions (addr) {
|
||||
}
|
||||
const unexpectedErrorMessage = `An unexpected error occurred fetching the Lightning Address metadata for ${addr}. Check the address and try again.`
|
||||
let res
|
||||
const url = `${protocol}://${domain}/.well-known/lnurlp/${name}`
|
||||
try {
|
||||
const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`)
|
||||
const req = await fetch(url, { signal })
|
||||
res = await req.json()
|
||||
} catch (err) {
|
||||
// If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error
|
||||
console.log('Error fetching lnurlp', err)
|
||||
if (err.name === 'TimeoutError') {
|
||||
throw new FetchTimeoutError('GET', url, WALLET_CREATE_INVOICE_TIMEOUT_MS)
|
||||
}
|
||||
// If `fetch` fails, or if `req.json` fails, catch it here and surface a reasonable error
|
||||
throw new Error(unexpectedErrorMessage)
|
||||
}
|
||||
if (res.status === 'ERROR') {
|
||||
|
294
lib/nostr.js
294
lib/nostr.js
@ -1,8 +1,6 @@
|
||||
import { bech32 } from 'bech32'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { callWithTimeout, withTimeout } from '@/lib/time'
|
||||
import crypto from 'crypto'
|
||||
import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
|
||||
|
||||
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
|
||||
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
|
||||
@ -17,154 +15,148 @@ 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
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('websocket error:', err.message)
|
||||
this.error = err.message
|
||||
/**
|
||||
* @type {NDK}
|
||||
*/
|
||||
get ndk () {
|
||||
return this._ndk
|
||||
}
|
||||
|
||||
this.ws = ws
|
||||
this.url = relayUrl
|
||||
this.error = null
|
||||
/**
|
||||
*
|
||||
* @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
|
||||
}
|
||||
|
||||
static async connect (url, { timeout } = {}) {
|
||||
const relay = new Relay(url)
|
||||
await relay.waitUntilConnected({ timeout })
|
||||
return relay
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
|
||||
get connected () {
|
||||
return this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
/**
|
||||
* @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 })
|
||||
|
||||
get closed () {
|
||||
return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED
|
||||
}
|
||||
const successfulRelays = []
|
||||
const failedRelays = []
|
||||
|
||||
async waitUntilConnected ({ timeout } = {}) {
|
||||
let interval
|
||||
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)
|
||||
|
||||
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)
|
||||
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)
|
||||
let noteId = null
|
||||
if (signedEvent.kind !== 1) {
|
||||
noteId = await nip19.naddrEncode({
|
||||
kind: signedEvent.kind,
|
||||
pubkey: signedEvent.pubkey,
|
||||
identifier: signedEvent.tags[0][1]
|
||||
})
|
||||
} else {
|
||||
reject(new Error(reason || `event rejected: ${eventId}`))
|
||||
}
|
||||
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)
|
||||
return { successfulRelays, failedRelays, noteId }
|
||||
} catch (error) {
|
||||
console.error('Crosspost error:', error)
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', listener)
|
||||
|
||||
ws.send(JSON.stringify(['REQ', id, ...filter]))
|
||||
})
|
||||
|
||||
try {
|
||||
return await withTimeout(ackPromise, timeout)
|
||||
} finally {
|
||||
ws.removeEventListener('message', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
|
25
lib/time.js
25
lib/time.js
@ -128,12 +128,22 @@ function tzOffset (tz) {
|
||||
return targetOffsetHours
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor (timeout) {
|
||||
super(`timeout after ${timeout / 1000}s`)
|
||||
this.name = 'TimeoutError'
|
||||
this.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
function timeoutPromise (timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// if no timeout is specified, never settle
|
||||
if (!timeout) return
|
||||
|
||||
setTimeout(() => reject(new Error(`timeout after ${timeout / 1000}s`)), timeout)
|
||||
// delay timeout by 100ms so any parallel promise with same timeout will throw first
|
||||
const delay = 100
|
||||
setTimeout(() => reject(new TimeoutError(timeout)), timeout + delay)
|
||||
})
|
||||
}
|
||||
|
||||
@ -144,3 +154,16 @@ export async function withTimeout (promise, timeout) {
|
||||
export async function callWithTimeout (fn, timeout) {
|
||||
return await Promise.race([fn(), timeoutPromise(timeout)])
|
||||
}
|
||||
|
||||
// AbortSignal.timeout with our custom timeout error message
|
||||
export function timeoutSignal (timeout) {
|
||||
const controller = new AbortController()
|
||||
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
controller.abort(new TimeoutError(timeout))
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
return controller.signal
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
273
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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'>
|
||||
|
@ -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;
|
@ -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;
|
@ -924,7 +924,9 @@ model Invoice {
|
||||
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?
|
||||
|
20
scripts/test-routing.sh
Normal file
20
scripts/test-routing.sh
Normal 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
|
@ -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,19 +13,20 @@ 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, `
|
||||
async function payInvoice (bolt11, { apiKey, wallet }, { signal }) {
|
||||
const out = await request({
|
||||
apiKey,
|
||||
query: `
|
||||
mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
|
||||
lnInvoicePaymentSend(input: $input) {
|
||||
status
|
||||
@ -44,14 +46,15 @@ async function payInvoice (authToken, wallet, invoice) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
}`,
|
||||
variables: {
|
||||
input: {
|
||||
paymentRequest: invoice,
|
||||
walletId
|
||||
paymentRequest: bolt11,
|
||||
walletId: wallet.id
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { 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,11 +98,12 @@ 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, `
|
||||
out = await request({
|
||||
apiKey,
|
||||
query: `
|
||||
query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
|
||||
me {
|
||||
defaultAccount {
|
||||
@ -119,12 +123,12 @@ async function getTxInfo (authToken, wallet, invoice) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
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
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { fetchWithTimeout } from '@/lib/fetch'
|
||||
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
|
||||
|
||||
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
|
||||
@ -7,8 +8,10 @@ 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 {
|
||||
@ -18,27 +21,29 @@ export async function getWallet (authToken, currency) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {})
|
||||
}`
|
||||
}, { 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, `
|
||||
export async function getScopes ({ apiKey }, { signal }) {
|
||||
const out = await request({
|
||||
apiKey,
|
||||
query: `
|
||||
query scopes {
|
||||
authorization {
|
||||
scopes
|
||||
}
|
||||
}
|
||||
`, {})
|
||||
}`
|
||||
}, { signal })
|
||||
const scopes = out?.data?.authorization?.scopes
|
||||
return scopes || []
|
||||
}
|
||||
|
@ -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,22 +14,25 @@ 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 = `
|
||||
|
||||
const out = await request({
|
||||
apiKey,
|
||||
query: `
|
||||
mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
|
||||
lnInvoiceCreate(input: $input) {
|
||||
invoice {
|
||||
@ -40,22 +42,22 @@ export async function createInvoice (
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const out = await request(apiKeyRecv, mutation, {
|
||||
}`,
|
||||
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
|
||||
}
|
||||
|
@ -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({
|
||||
{ msats, description, expiry },
|
||||
{ socket, rune, cert },
|
||||
{ signal }) => {
|
||||
const inv = await clnCreateInvoice(
|
||||
{
|
||||
msats,
|
||||
description,
|
||||
expiry
|
||||
},
|
||||
{
|
||||
socket,
|
||||
rune,
|
||||
cert,
|
||||
description,
|
||||
descriptionHash,
|
||||
msats,
|
||||
expiry
|
||||
})
|
||||
cert
|
||||
},
|
||||
{ signal })
|
||||
|
||||
return inv.bolt11
|
||||
}
|
||||
|
@ -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,18 +39,29 @@ export function useWalletConfigurator (wallet) {
|
||||
let serverConfig = serverWithShared
|
||||
|
||||
if (canSend({ def: wallet.def, config: clientConfig })) {
|
||||
try {
|
||||
let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true })
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
if (wallet.def.testSendPayment && validateLightning) {
|
||||
transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger })
|
||||
transformedConfig = await withTimeout(
|
||||
wallet.def.testSendPayment(clientConfig, {
|
||||
logger,
|
||||
signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
|
||||
}),
|
||||
WALLET_SEND_PAYMENT_TIMEOUT_MS
|
||||
)
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
// validate again to ensure generated fields are valid
|
||||
await validateWallet(wallet.def, clientConfig)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
|
||||
const transformedConfig = await validateWallet(wallet.def, serverConfig)
|
||||
if (transformedConfig) {
|
||||
@ -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 }
|
||||
}
|
||||
|
@ -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')
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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}`, {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(`${agent.protocol}//${hostname}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent,
|
||||
body
|
||||
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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentError instanceof WalletReceiverError) {
|
||||
// if payment failed because of the receiver, use the same wallet again
|
||||
// and log this as info, not error
|
||||
logger.info('failed to forward payment to receiver, retrying with new invoice', { bolt11 })
|
||||
i -= 1
|
||||
} else if (paymentError instanceof WalletPaymentError) {
|
||||
// only log payment errors, not configuration errors
|
||||
logger.error(message, { bolt11 })
|
||||
}
|
||||
|
||||
if (paymentError instanceof WalletPaymentError) {
|
||||
// if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry.
|
||||
await invoiceHelper.cancel(latestInvoice)
|
||||
}
|
||||
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: receiver fallbacks
|
||||
//
|
||||
// if payment failed because of the receiver, we should use the same wallet again.
|
||||
// if (err instanceof ReceiverError) { ... }
|
||||
aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice)
|
||||
|
||||
// try next wallet if the payment failed because of the wallet
|
||||
// and not because it expired or was canceled
|
||||
if (err instanceof WalletError) {
|
||||
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
|
||||
continue
|
||||
}
|
||||
|
||||
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
|
||||
throw err
|
||||
} 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])
|
||||
}, [])
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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({
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user