Compare commits

..

No commits in common. "6d4dfddae8547e8837a682249acd13f818642dd4" and "080459cd21bb58b8309d66d862aea315f439bf8d" have entirely different histories.

81 changed files with 875 additions and 1949 deletions

View File

@ -29,8 +29,8 @@ SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
# lnurl ... you'll need a tunnel to localhost:3000 for these
LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith
LNAUTH_URL=
LNWITH_URL=
########################################
# SNDEV STUFF WE PRESET #
@ -126,42 +126,27 @@ RPC_PORT=18443
P2P_PORT=18444
ZMQ_BLOCK_PORT=28334
ZMQ_TX_PORT=28335
ZMQ_HASHBLOCK_PORT=29000
# sn_lnd container stuff
SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735
# sn lnd container stuff
LND_REST_PORT=8080
LND_GRPC_PORT=10009
LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
SN_LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# lnd container stuff
LND_REST_PORT=8081
LND_GRPC_PORT=10010
# stacker lnd container stuff
STACKER_LND_REST_PORT=8081
STACKER_LND_GRPC_PORT=10010
# docker exec -u lnd lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
STACKER_LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
STACKER_LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# cln container stuff
CLN_REST_PORT=9092
# stacker cln container stuff
STACKER_CLN_REST_PORT=9092
# docker exec -u clightning cln lightning-cli newaddr bech32
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
STACKER_CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
STACKER_CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
LNCLI_NETWORK=regtest

5
.gitignore vendored
View File

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

View File

@ -317,39 +317,34 @@ export async function retryPaidAction (actionType, args, incomingContext) {
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
cost: BigInt(msatsRequested),
actionId,
predecessorId: failedInvoice.id
actionId
}
let invoiceArgs
const invoiceForward = await models.invoiceForward.findUnique({
where: {
invoiceId: failedInvoice.id
},
where: { invoiceId: failedInvoice.id },
include: {
wallet: true
wallet: true,
invoice: true,
withdrawl: true
}
})
if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
try {
const { userId } = invoiceForward.wallet
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err)
}
// TODO: receiver fallbacks
// use next receiver wallet if forward failed (we currently immediately fallback to SN)
const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED'
if (invoiceForward && !failedForward) {
const { userId } = invoiceForward.wallet
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} else {
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
}
invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext)
return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs }
@ -409,7 +404,7 @@ async function createSNInvoice (actionType, args, context) {
}
async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
const db = tx ?? models
@ -434,8 +429,7 @@ async function createDbInvoice (actionType, args, context) {
actionOptimistic: optimistic,
actionArgs: args,
expiresAt,
actionId,
predecessorId
actionId
}
let invoice

View File

@ -1,4 +1,4 @@
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { 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,8 +44,7 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId },
lnd,
request: withdrawal.bolt11,
max_fee: msatsToSats(withdrawal.msatsFeePaying),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
confidence: LND_PATHFINDING_TIME_PREF_PPM
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS
}).catch(console.error)
return withdrawal

View File

@ -8,8 +8,7 @@ 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,
ITEM_EDIT_SECONDS
BOOST_MULT
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
@ -1351,9 +1350,8 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
throw new GqlInputError('item is deleted')
}
const meId = Number(me?.id ?? USER_ID.anon)
// author can edit their own item (except anon)
const meId = Number(me?.id ?? USER_ID.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)
@ -1362,9 +1360,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
const ownerEdit = authorEdit || adminEdit || hmacEdit
if (!ownerEdit) {
if (!authorEdit && !adminEdit && !hmacEdit) {
throw new GqlInputError('item does not belong to you')
}
@ -1381,11 +1379,12 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
const user = await models.user.findUnique({ where: { id: meId } })
// edits are only allowed for own items within 10 minutes but forever if it's their bio or a job
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const myBio = user.bioId === old.id
const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS })
const canEdit = (timer && ownerEdit) || myBio || isJob(item)
if (!canEdit) {
const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { minutes: 10 })
// timer permission check
if (!adminEdit && !myBio && !timer && !isJob(item)) {
throw new GqlInputError('item can no longer be edited')
}

View File

@ -157,7 +157,7 @@ export default {
const [{ to, from }] = await models.$queryRaw`
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 500 }, { models, ...context })
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
},
total: async (parent, args, { models }) => {
if (!parent.total) {

View File

@ -66,12 +66,11 @@ 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 * ${column === 'proportion' ? ', proportion' : ''}
SELECT *
FROM
(SELECT users.*,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,

View File

@ -8,8 +8,7 @@ import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS
PAID_ACTION_PAYMENT_METHODS
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
@ -22,10 +21,9 @@ import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import { canReceive } 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:')
@ -65,15 +63,9 @@ function injectResolvers (resolvers) {
return await upsertWallet({
wallet,
walletDef,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
? (data) => walletDef.testCreateInvoice(data, { logger, me, models })
: null
}, {
settings,
@ -559,10 +551,7 @@ const resolvers = {
const logger = walletLogger({ wallet, models })
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
logger.info('details for receiving deleted')
}
logger.info('wallet detached')
return true
},
@ -617,15 +606,6 @@ 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) },
@ -770,7 +750,7 @@ export const walletLogger = ({ wallet, models }) => {
}
async function upsertWallet (
{ wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
@ -876,26 +856,24 @@ 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 ? 'details for receiving updated' : 'details for receiving saved'
}
}),
models.walletLog.create({
data: {
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receiving enabled' : 'receiving disabled'
}
})
)
}
txs.push(
models.walletLog.createMany({
data: {
userId: me.id,
wallet: wallet.type,
level: 'SUCCESS',
message: id ? 'wallet details updated' : 'wallet attached'
}
}),
models.walletLog.create({
data: {
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'wallet enabled' : 'wallet disabled'
}
})
)
const [upsertedWallet] = await models.$transaction(txs)
return upsertedWallet

View File

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

View File

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

View File

@ -152,11 +152,10 @@ 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,2024-12-09
felipebueno,pr,#1620,,medium,,,1,225k,felipebueno@getalby.com,???
Soxasora,pr,#1647,#1645,easy,,,,100k,soxasora@blink.sv,2024-12-07
Soxasora,pr,#1667,#1568,easy,,,,100k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1633,#1471,easy,,,1,90k,aegroto@blink.sv,2024-12-07
Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07
Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07
sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10

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

View File

@ -11,7 +11,7 @@ RUN npm ci
COPY . .
ADD 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
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
RUN dpkg -i fonts-noto-color-emoji.deb
CMD [ "node", "index.js" ]
USER pptruser
USER pptruser

View File

@ -232,15 +232,9 @@ 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 =>
// 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,
waitFor: inv => inv?.satsReceived > 0,
...options,
update: (cache, { data }) => {
const response = getPaidActionResult(data)

View File

@ -36,7 +36,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
await window.webln.enable()
await window.webln.lnurl(encodedUrl)
}
effect().catch(console.error)
effect()
}, [encodedUrl])
// output pubkey and k1

View File

@ -2,7 +2,6 @@ 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()
@ -17,10 +16,10 @@ export default function Navigation ({ sub }) {
}
return (
<PriceCarouselProvider>
<>
<DesktopHeader {...props} />
<MobileHeader {...props} />
<StickyBar {...props} />
</PriceCarouselProvider>
</>
)
}

View File

@ -1,46 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
const STORAGE_KEY = 'asSats'
const DEFAULT_SELECTION = 'fiat'
const carousel = [
'fiat',
'yep',
'1btc',
'blockHeight',
'chainFee',
'halving'
]
export const PriceCarouselContext = createContext({
selection: undefined,
handleClick: () => {}
})
export function PriceCarouselProvider ({ children }) {
const [selection, setSelection] = useState(undefined)
const [pos, setPos] = useState(0)
useEffect(() => {
const selection = window.localStorage.getItem(STORAGE_KEY) ?? DEFAULT_SELECTION
setSelection(selection)
setPos(carousel.findIndex((item) => item === selection))
}, [])
const handleClick = useCallback(() => {
const nextPos = (pos + 1) % carousel.length
window.localStorage.setItem(STORAGE_KEY, carousel[nextPos])
setSelection(carousel[nextPos])
setPos(nextPos)
}, [pos])
return (
<PriceCarouselContext.Provider value={[selection, handleClick]}>
{children}
</PriceCarouselContext.Provider>
)
}
export function usePriceCarousel () {
return useContext(PriceCarouselContext)
}

View File

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

View File

@ -1,78 +1,71 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useEffect, useState } 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'
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 NostrError ({ message }) {
function ExtensionError ({ message, details }) {
return (
<>
<h4 className='fw-bold text-danger pb-1'>error</h4>
<div className='text-muted pb-4'>{message}</div>
<h4 className='fw-bold text-danger pb-1'>error: {message}</h4>
<div className='text-muted pb-4'>{details}</div>
</>
)
}
function NostrExplainer ({ text }) {
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>
</>
)
}
export function NostrAuth ({ text, callbackUrl, multiAuth }) {
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`
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
createAuth {
k1
@ -81,253 +74,83 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
// don't cache this mutation
fetchPolicy: 'no-cache'
})
// 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 clearSuggestionTimer = () => {
if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current)
}
const setSuggestionWithTimer = (msg) => {
clearSuggestionTimer()
suggestionTimeout.current = setTimeout(() => {
setSuggestion(msg)
}, 10_000)
}
const [hasExtension, setHasExtension] = useState(undefined)
const [extensionError, setExtensionError] = useState(null)
useEffect(() => {
return () => {
clearSuggestionTimer()
}
createAuth()
setHasExtension(!!window.nostr)
}, [])
// 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
const k1 = data?.createAuth.k1
if (!k1) throw new Error('Error generating challenge') // should never happen
useEffect(() => {
if (!k1 || !hasExtension) return
const useExtension = !nip46token
const signer = Nostr.getSigner({ nip46token, supportNip07: useExtension })
if (!signer && useExtension) throw new Error('No extension found')
console.info('nostr extension detected')
if (signer instanceof NDKNip46Signer) {
signer.once('authUrl', challengeResolver)
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
}
// sign them in
try {
await signIn('nostr', {
event: JSON.stringify(event),
callbackUrl,
multiAuth
})
} catch (e) {
throw new Error('authorization failed', e)
}
} catch (e) {
if (!mounted) return
console.log('nostr auth error', e)
setExtensionError({ message: `${text} failed`, details: e.message })
}
})()
return () => { mounted = false }
}, [k1, hasExtension])
setSuggestionWithTimer('Having trouble? Make sure you used a fresh token or valid NIP-05 address')
await signer.blockUntilReady()
clearSuggestionTimer()
setStatus({
msg: 'Signing in',
error: false,
loading: true
})
const signedEvent = await Nostr.sign({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['challenge', k1],
['u', process.env.NEXT_PUBLIC_URL],
['method', 'GET']
],
content: 'Stacker News Authentication'
}, { signer })
await signIn('nostr', {
event: JSON.stringify(signedEvent),
callbackUrl,
multiAuth
})
} catch (e) {
setError(e)
} finally {
clearSuggestionTimer()
}
}, [])
if (error) return <div>error</div>
return (
<>
{status.error && <NostrError message={status.msg} />}
{status.loading
? (
<>
<div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'>
<Moon className='spin fill-grey flex-shrink-0' width='30' height='30' />
{status.msg}
</div>
{status.button && (
<Button
className='w-100' variant='primary'
onClick={() => status.button.action()}
>
{status.button.label}
</Button>
)}
{suggestion && (
<div className='text-muted text-center small pt-2'>{suggestion}</div>
)}
</>
)
: (
<>
<Form
initial={{ token: '' }}
onSubmit={values => {
if (!values.token) {
setError(new Error('Token or NIP-05 address is required'))
} else {
auth(values.token)
}
}}
>
<Input
label='Connect with token or NIP-05 address'
name='token'
placeholder='bunker://... or NIP-05 address'
required
autoFocus
/>
<div className='mt-2'>
<SubmitButton className='w-100' variant='primary'>
{text || 'Login'} with token or NIP-05
</SubmitButton>
</div>
</Form>
<div className='text-center text-muted fw-bold my-2'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</>
)}
{hasExtension === false && <NostrExplainer text={text} />}
{extensionError && <ExtensionError {...extensionError} />}
{hasExtension && !extensionError &&
<>
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
</>}
</>
)
}
function NostrExplainer ({ text, children }) {
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
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>
<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>
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</div>
</Container>
)
}
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return (
<NostrExplainer text={text}>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</NostrExplainer>
)
}

View File

@ -501,23 +501,13 @@ function Invoicification ({ n: { invoice, sortTime } }) {
}
function WithdrawlPaid ({ n }) {
let amount = n.earnedSats + n.withdrawl.satsFeePaid
let actionString = 'withdrawn from your account'
if (n.withdrawl.autoWithdraw) {
actionString = 'sent to your attached wallet'
}
let actionString = n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account'
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(amount, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
{actionString}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{(n.withdrawl.forwardedActionType === 'ZAP' && <Badge className={styles.badge} bg={null}>p2p</Badge>) ||

View File

@ -1,4 +1,4 @@
import React, { useContext, useMemo } from 'react'
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import { fixedDecimal } from '@/lib/format'
import { useMe } from './me'
@ -8,7 +8,6 @@ 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,
@ -44,16 +43,43 @@ 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 [selection, handleClick] = usePriceCarousel()
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 { 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 (selection === 'yep') {
if (asSats === 'yep') {
if (!price || price < 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -62,7 +88,7 @@ export default function Price ({ className }) {
)
}
if (selection === '1btc') {
if (asSats === '1btc') {
return (
<div className={compClassName} onClick={handleClick} variant='link'>
1sat=1sat
@ -70,7 +96,7 @@ export default function Price ({ className }) {
)
}
if (selection === 'blockHeight') {
if (asSats === 'blockHeight') {
if (blockHeight <= 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -79,7 +105,7 @@ export default function Price ({ className }) {
)
}
if (selection === 'halving') {
if (asSats === 'halving') {
if (!halving) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -88,7 +114,7 @@ export default function Price ({ className }) {
)
}
if (selection === 'chainFee') {
if (asSats === 'chainFee') {
if (chainFee <= 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>
@ -97,7 +123,7 @@ export default function Price ({ className }) {
)
}
if (selection === 'fiat') {
if (asSats === 'fiat') {
if (!price || price < 0) return null
return (
<div className={compClassName} onClick={handleClick} variant='link'>

View File

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

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'
import { datePivot } from '@/lib/time'
import { useMe } from '@/components/me'
import { ITEM_EDIT_SECONDS, USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
export default function useCanEdit (item) {
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS })
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
const { me } = useMe()
// deleted items can never be edited and every item has a 10 minute edit window

View File

@ -1,7 +1,8 @@
import { useCallback } from 'react'
import { useToast } from './toast'
import { Button } from 'react-bootstrap'
import Nostr, { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import { DEFAULT_CROSSPOSTING_RELAYS, crosspost } from '@/lib/nostr'
import { callWithTimeout } from '@/lib/time'
import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
import { SETTINGS } from '@/fragments/users'
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'
@ -203,7 +204,7 @@ export default function useCrossposter () {
do {
try {
const result = await Nostr.crosspost(event, { relays: failedRelays || relays })
const result = await crosspost(event, failedRelays || relays)
if (result.error) {
failedRelays = []
@ -238,6 +239,13 @@ export default function useCrossposter () {
}
const handleCrosspost = useCallback(async (itemId) => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 10000)
if (!pubkey) throw new Error('failed to get pubkey')
} catch (e) {
throw new Error(`Nostr extension error: ${e.message}`)
}
let noteId
try {

View File

@ -1,8 +1,8 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
import { InvoiceExpiredError, InvoiceCanceledError } from '@/wallets/errors'
export default function useInvoice () {
const client = useApolloClient()
@ -16,21 +16,20 @@ export default function useInvoice () {
throw error
}
const { cancelled, cancelledAt, actionError, expiresAt, forwardStatus } = data.invoice
const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
if (expired) {
throw new InvoiceExpiredError(data.invoice)
}
const failedForward = forwardStatus && forwardStatus !== 'CONFIRMED'
if (failedForward) {
throw new WalletReceiverError(data.invoice)
if (cancelled || actionError) {
throw new InvoiceCanceledError(data.invoice, actionError)
}
const failed = cancelled || actionError
if (failed) {
throw new InvoiceCanceledError(data.invoice, actionError)
// write to cache if paid
if (actionState === 'PAID') {
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
}
return { invoice: data.invoice, check: that(data.invoice) }

View File

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

View File

@ -133,7 +133,7 @@ export function useWalletLogManager (setLogs) {
`,
{
onCompleted: (_, { variables: { wallet: walletType } }) => {
setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== walletTag(getWalletByType(walletType)) : false))
setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : 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)
}
}, [setLogs, loadLogsPage, page, logsPerPage, wallet?.def, hasMore])
}, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore])
const loadNew = useCallback(async () => {
const latestTs = latestTimestamp.current

View File

@ -241,15 +241,12 @@ 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'
@ -265,8 +262,6 @@ 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: >
@ -275,14 +270,12 @@ services:
command bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} "$$@"
}
blockcount=$$(bitcoin-cli getblockcount 2>/dev/null)
nodes=(${SN_LND_ADDR} ${LND_ADDR} ${CLN_ADDR} ${ROUTER_LND_ADDR} ${ECLAIR_ADDR})
nodes=(${LND_ADDR} ${STACKER_LND_ADDR} ${STACKER_CLN_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, eclair..."
echo "Mining 100 blocks to sn_lnd, lnd, cln..."
for addr in "$${nodes[@]}"; do
bitcoin-cli generatetoaddress 100 $$addr
echo "Mining 100 blocks to a random address..."
@ -348,15 +341,11 @@ 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:
- "${SN_LND_REST_PORT}:8080"
- "${SN_LND_GRPC_PORT}:10009"
- "${LND_REST_PORT}:8080"
- "${LND_GRPC_PORT}:10009"
volumes:
- sn_lnd:/home/lnd/.lnd
labels:
@ -369,39 +358,11 @@ services:
if [ $$(lncli getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then
exit 0
else
lncli openchannel --node_key=$ROUTER_LND_PUBKEY --connect router_lnd:9735 --sat_per_vbyte 1 \\
lncli openchannel --node_key=$STACKER_LND_PUBKEY --connect 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
@ -451,8 +412,8 @@ services:
- "9735"
- "10009"
ports:
- "${LND_REST_PORT}:8080"
- "${LND_GRPC_PORT}:10009"
- "${STACKER_LND_REST_PORT}:8080"
- "${STACKER_LND_GRPC_PORT}:10009"
volumes:
- lnd:/home/lnd/.lnd
- tordata:/home/lnd/.tor
@ -468,7 +429,7 @@ services:
if [ $$(lncli getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then
exit 0
else
lncli openchannel --node_key=$ROUTER_LND_PUBKEY --connect router_lnd:9735 --sat_per_vbyte 1 \\
lncli openchannel --node_key=$LND_PUBKEY --connect sn_lnd:9735 --sat_per_vbyte 1 \\
--min_confs 0 --local_amt=1000000000 --push_amt=500000000
fi
"
@ -516,7 +477,7 @@ services:
container_name: cln
restart: unless-stopped
profiles:
- wallets
- payments
healthcheck:
<<: *healthcheck
test: ["CMD-SHELL", "su clightning -c 'lightning-cli --network=regtest getinfo'"]
@ -528,8 +489,6 @@ services:
env_file: *env_file
command:
- 'lightningd'
- '--addr=0.0.0.0:9735'
- '--announce-addr=cln:9735'
- '--network=regtest'
- '--alias=cln'
- '--bitcoin-rpcconnect=bitcoin'
@ -538,10 +497,11 @@ services:
- '--large-channels'
- '--rest-port=3010'
- '--rest-host=0.0.0.0'
- '--log-file=/home/clightning/.lightning/debug.log'
expose:
- "9735"
ports:
- "${CLN_REST_PORT}:3010"
- "${STACKER_CLN_REST_PORT}:3010"
volumes:
- cln:/home/clightning/.lightning
- tordata:/home/clightning/.tor
@ -557,120 +517,12 @@ services:
if [ $$(lightning-cli --regtest getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then
exit 0
else
lightning-cli --regtest connect $ROUTER_LND_PUBKEY@router_lnd:9735
lightning-cli --regtest fundchannel id=$ROUTER_LND_PUBKEY feerate=1000perkb \\
lightning-cli --regtest connect $LND_PUBKEY@sn_lnd:9735
lightning-cli --regtest fundchannel id=$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
@ -680,6 +532,7 @@ services:
- bitcoin
- sn_lnd
- lnd
- cln
restart: unless-stopped
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
volumes:
@ -807,9 +660,7 @@ volumes:
sn_lnd:
lnd:
cln:
router_lnd:
s3:
nwc_send:
nwc_recv:
tordata:
eclair:

View File

@ -1,68 +0,0 @@
# based on https://github.com/LN-Zap/bolt12-playground
FROM acinq/eclair:0.11.0
ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh "-Declair.datadir=${ECLAIR_DATADIR}"
#################
# Builder image #
#################
FROM maven:3.8.6-openjdk-11-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# References for eclair
ARG ECLAIR_REF=b73a009a1d7d7ea3a158776cd233512b9a538550
ARG ECLAIR_PLUGINS_REF=cdc26dda96774fdc3b54075df078587574891fb7
WORKDIR /usr/src/eclair
RUN git clone https://github.com/ACINQ/eclair.git . \
&& git reset --hard ${ECLAIR_REF}
RUN mvn install -pl eclair-node -am -DskipTests -Dgit.commit.id=notag -Dgit.commit.id.abbrev=notag
WORKDIR /usr/src/eclair-plugins
RUN git clone https://github.com/ACINQ/eclair-plugins.git . \
&& git reset --hard ${ECLAIR_PLUGINS_REF}
WORKDIR /usr/src/eclair-plugins/bolt12-tip-jar
RUN mvn package -DskipTests
# ###############
# # final image #
# ###############
FROM openjdk:11.0.16-jre-slim-bullseye
WORKDIR /opt
# Add utils
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash jq curl unzip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# copy and install eclair-cli executable
COPY --from=builder /usr/src/eclair/eclair-core/eclair-cli .
RUN chmod +x eclair-cli && mv eclair-cli /sbin/eclair-cli
# we only need the eclair-node.zip to run
COPY --from=builder /usr/src/eclair/eclair-node/target/eclair-node-*.zip ./eclair-node.zip
RUN unzip eclair-node.zip && mv eclair-node-* eclair-node && chmod +x eclair-node/bin/eclair-node.sh
# copy and install bolt12-tip-jar plugin
COPY --from=builder /usr/src/eclair-plugins/bolt12-tip-jar/target/bolt12-tip-jar-0.10.1-SNAPSHOT.jar .
ENV ECLAIR_DATADIR=/data
ENV JAVA_OPTS=
RUN mkdir -p "$ECLAIR_DATADIR"
VOLUME [ "/data" ]
ARG LN_NODE_FOR
ENV LN_NODE_FOR=$LN_NODE_FOR
COPY ["./$LN_NODE_FOR/*", "/data"]
# ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh "-Declair.datadir=${ECLAIR_DATADIR}"
ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh bolt12-tip-jar-0.10.1-SNAPSHOT.jar "-Declair.datadir=${ECLAIR_DATADIR}"

View File

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

View File

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

View File

@ -1 +0,0 @@
sqlite

Binary file not shown.

View File

@ -1,15 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICRzCCAe2gAwIBAgIRALrTKBEy2NhGUue4RgGKhpgwCgYIKoZIzj0EAwIwODEf
MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNWRhMzQx
OTEwZDAyMB4XDTI0MTIwOTA4MzcxOVoXDTI2MDIwMzA4MzcxOVowODEfMB0GA1UE
ChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNWRhMzQxOTEwZDAy
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFPJk3jfBfWyHM7TB2pCJ45J5VqVI
r9x4nvBIZPQdvizgV4qqiNnnKTohZtH7eJ/T/epN3V9UNH3jW5MTcnIv+qOB1zCB
1DAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/
BAUwAwEB/zAdBgNVHQ4EFgQUN7Er1+iR3NeiwJXqLMD6CXb86qIwfQYDVR0RBHYw
dIIMNWRhMzQxOTEwZDAygglsb2NhbGhvc3SCCnJvdXRlcl9sbmSCFGhvc3QuZG9j
a2VyLmludGVybmFsggR1bml4ggp1bml4cGFja2V0ggdidWZjb25uhwR/AAABhxAA
AAAAAAAAAAAAAAAAAAABhwSsEgAJMAoGCCqGSM49BAMCA0gAMEUCIAucaM+ZivUy
G2PDcCfQZGDa0O8XVGQwofI2ZpMQwVe6AiEA9vYnOSZG1ozi0IKNgqbEs3ObByjE
dM+krTDuPzk8Kd4=
-----END CERTIFICATE-----

View File

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

View File

@ -1,17 +0,0 @@
# 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}"

View File

@ -1,10 +0,0 @@
-----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-----

View File

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

View File

@ -254,7 +254,7 @@ export const TOP_USERS = gql`
photoId
ncomments(when: $when, from: $from, to: $to)
nposts(when: $when, from: $from, to: $to)
proportion
optional {
stacked(when: $when, from: $from, to: $to)
spent(when: $when, from: $from, to: $to)

View File

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

View File

@ -2,44 +2,30 @@ 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 ({ msats, description, expiry }, { socket, rune, cert }, { signal }) => {
export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => {
const agent = getAgent({ hostname: socket, cert })
const url = `${agent.protocol}//${socket}/v1/invoice`
let res
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Rune: rune,
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490'
},
agent,
body: JSON.stringify({
// CLN requires a unique label for every invoice
// see https://docs.corelightning.org/reference/lightning-invoice
label: crypto.randomBytes(16).toString('hex'),
description,
amount_msat: msats,
expiry
}),
signal
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Rune: rune,
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490'
},
agent,
body: JSON.stringify({
// CLN requires a unique label for every invoice
// see https://docs.corelightning.org/reference/lightning-invoice
label: crypto.randomBytes(16).toString('hex'),
description,
amount_msat: msats,
expiry
})
} catch (err) {
if (err.name === 'AbortError') {
// XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually.
// see https://github.com/node-fetch/node-fetch/issues/1462
throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS)
}
throw err
}
})
assertResponseOk(res)
assertContentTypeJson(res)

View File

@ -46,7 +46,6 @@ 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
@ -80,7 +79,6 @@ 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
@ -191,6 +189,3 @@ export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTER
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
export const ZAP_UNDO_DELAY_MS = 5_000
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 15_000
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 15_000

View File

@ -1,28 +1,14 @@
import { TimeoutError, timeoutSignal } from '@/lib/time'
export async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), timeout)
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`
}
}
const response = await fetch(resource, {
...options,
signal: controller.signal
})
clearTimeout(id)
export async function fetchWithTimeout (resource, { signal, timeout = 1000, ...options } = {}) {
try {
return await fetch(resource, {
...options,
signal: signal ?? timeoutSignal(timeout)
})
} catch (err) {
if (err.name === 'TimeoutError') {
// use custom error message
throw new FetchTimeoutError(options.method ?? 'GET', resource, err.timeout)
}
throw err
}
return response
}
class LRUCache {

View File

@ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => {
return 'hot'
}
export const isJob = item => item.subName === 'jobs'
export const isJob = item => item.subName !== 'jobs'
// a delete directive preceded by a non word character that isn't a backtick
const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi

View File

@ -1,8 +1,6 @@
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'))
@ -27,7 +25,7 @@ export function lnurlPayDescriptionHash (data) {
return createHash('sha256').update(data).digest('hex')
}
export async function lnAddrOptions (addr, { signal } = {}) {
export async function lnAddrOptions (addr) {
await lnAddrSchema().fields.addr.validate(addr)
const [name, domain] = addr.split('@')
let protocol = 'https'
@ -37,16 +35,12 @@ export async function lnAddrOptions (addr, { signal } = {}) {
}
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(url, { signal })
const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`)
res = await req.json()
} catch (err) {
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
console.log('Error fetching lnurlp', err)
throw new Error(unexpectedErrorMessage)
}
if (res.status === 'ERROR') {

View File

@ -1,6 +1,8 @@
import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
import WebSocket from 'isomorphic-ws'
import { callWithTimeout, withTimeout } from '@/lib/time'
import crypto from 'crypto'
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
@ -15,149 +17,155 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://nostr.mutinywallet.com/',
'wss://relay.mutinywallet.com/'
]
export const RELAYS_BLACKLIST = []
/* eslint-disable camelcase */
export class Relay {
constructor (relayUrl) {
const ws = new WebSocket(relayUrl)
/**
* @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
})
}
/**
* @type {NDK}
*/
get ndk () {
return this._ndk
}
/**
*
* @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
}
/**
* @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
}
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {Array<string>} context.relays
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @param {number} context.timeout
* @returns {Promise<NDKEvent>}
*/
async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) {
const event = await this.sign({ kind, created_at, content, tags }, { signer })
const successfulRelays = []
const failedRelays = []
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)
event.on('relay:publish:failed', (relay, error) => {
failedRelays.push({ relay: relay.url, error })
})
for (const relay of (await relaySet.publish(event, timeout))) {
successfulRelays.push(relay.url)
ws.onmessage = (msg) => {
const [type, notice] = JSON.parse(msg.data)
if (type === 'NOTICE') {
console.log('relay notice:', notice)
}
}
return {
event,
successfulRelays,
failedRelays
ws.onerror = (err) => {
console.error('websocket error:', err.message)
this.error = err.message
}
this.ws = ws
this.url = relayUrl
this.error = null
}
async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) {
static async connect (url, { timeout } = {}) {
const relay = new Relay(url)
await relay.waitUntilConnected({ timeout })
return relay
}
get connected () {
return this.ws.readyState === WebSocket.OPEN
}
get closed () {
return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED
}
async waitUntilConnected ({ timeout } = {}) {
let interval
const checkPromise = new Promise((resolve, reject) => {
interval = setInterval(() => {
if (this.connected) {
resolve()
}
if (this.closed) {
reject(new Error(`failed to connect to ${this.url}: ` + this.error))
}
}, 100)
})
try {
signer ??= this.getSigner({ supportNip07: true })
const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout })
return await withTimeout(checkPromise, timeout)
} catch (err) {
this.close()
throw err
} finally {
clearInterval(interval)
}
}
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')
close () {
const state = this.ws.readyState
if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) {
this.ws.close()
}
}
async publish (event, { timeout } = {}) {
const ws = this.ws
let listener
const ackPromise = new Promise((resolve, reject) => {
listener = function onmessage (msg) {
const [type, eventId, accepted, reason] = JSON.parse(msg.data)
if (type !== 'OK' || eventId !== event.id) return
if (accepted) {
resolve(eventId)
} else {
reject(new Error(reason || `event rejected: ${eventId}`))
}
}
return { successfulRelays, failedRelays, noteId }
} catch (error) {
console.error('Crosspost error:', error)
return { error }
ws.addEventListener('message', listener)
ws.send(JSON.stringify(['EVENT', event]))
})
try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
}
}
async fetch (filter, { timeout } = {}) {
const ws = this.ws
let listener
const ackPromise = new Promise((resolve, reject) => {
const id = crypto.randomBytes(16).toString('hex')
const events = []
let eose = false
listener = function onmessage (msg) {
const [type, subId, event] = JSON.parse(msg.data)
if (subId !== id) return
if (type === 'EVENT') {
events.push(event)
if (eose) {
// EOSE was already received:
// return first event after EOSE
resolve(events)
}
return
}
if (type === 'CLOSED') {
return resolve(events)
}
if (type === 'EOSE') {
eose = true
if (events.length > 0) {
// we already received events before EOSE:
// return all events before EOSE
ws.send(JSON.stringify(['CLOSE', id]))
return resolve(events)
}
}
}
ws.addEventListener('message', listener)
ws.send(JSON.stringify(['REQ', id, ...filter]))
})
try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
}
}
}
/**
* @type {Nostr}
*/
export default new Nostr()
export function hexToBech32 (hex, prefix = 'npub') {
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
}
@ -179,10 +187,48 @@ export function nostrZapDetails (zap) {
return { npub, content, note }
}
// 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)
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 }
}
}

View File

@ -128,22 +128,12 @@ 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
// delay timeout by 100ms so any parallel promise with same timeout will throw first
const delay = 100
setTimeout(() => reject(new TimeoutError(timeout)), timeout + delay)
setTimeout(() => reject(new Error(`timeout after ${timeout / 1000}s`)), timeout)
})
}
@ -154,16 +144,3 @@ export async function withTimeout (promise, timeout) {
export async function callWithTimeout (fn, timeout) {
return await Promise.race([fn(), timeoutPromise(timeout)])
}
// AbortSignal.timeout with our custom timeout error message
export function timeoutSignal (timeout) {
const controller = new AbortController()
if (timeout) {
setTimeout(() => {
controller.abort(new TimeoutError(timeout))
}, timeout)
}
return controller.signal
}

View File

@ -203,12 +203,12 @@ export function parseNwcUrl (walletConnectUrl) {
const params = {}
params.walletPubkey = url.host
const secret = url.searchParams.get('secret')
const relayUrls = url.searchParams.getAll('relay')
const relayUrl = url.searchParams.get('relay')
if (secret) {
params.secret = secret
}
if (relayUrls) {
params.relayUrls = relayUrls
if (relayUrl) {
params.relayUrl = relayUrl
}
return params
}

View File

@ -147,15 +147,15 @@ addMethod(string, 'nwcUrl', function () {
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
try {
string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl)
let relayUrls, walletPubkey, secret
let relayUrl, walletPubkey, secret
try {
({ relayUrls, walletPubkey, secret } = parseNwcUrl(nwcUrl))
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
} catch {
// invalid URL error. handle as if pubkey validation failed to not confuse user.
throw new Error('pubkey must be 64 hex chars')
}
string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey)
array().of(string().required('relay url required').trim().wss('relay must use wss://')).min(1, 'at least one relay required').validateSync(relayUrls)
string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl)
string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret)
} catch (err) {
return context.createError({ message: err.message })

273
package-lock.json generated
View File

@ -15,7 +15,6 @@
"@graphql-tools/schema": "^10.0.6",
"@lightninglabs/lnc-web": "^0.3.2-alpha",
"@noble/curves": "^1.6.0",
"@nostr-dev-kit/ndk": "^2.10.5",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@slack/web-api": "^7.6.0",
@ -4372,15 +4371,6 @@
"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",
@ -4416,49 +4406,6 @@
"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",
@ -7363,19 +7310,6 @@
"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",
@ -8155,19 +8089,6 @@
"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",
@ -9047,46 +8968,6 @@
"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",
@ -9700,21 +9581,6 @@
"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",
@ -9809,16 +9675,6 @@
"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",
@ -9973,15 +9829,6 @@
}
]
},
"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",
@ -14307,15 +14154,6 @@
"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",
@ -15768,12 +15606,6 @@
"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",
@ -19576,23 +19408,11 @@
"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",
@ -19632,12 +19452,6 @@
"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",
@ -19760,26 +19574,11 @@
"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",
@ -20198,28 +19997,6 @@
}
}
},
"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",
@ -20543,47 +20320,6 @@
"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",
@ -21160,15 +20896,6 @@
"node": ">=10"
}
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"license": "MIT",
"engines": {
"node": ">=0.10.32"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -20,7 +20,6 @@
"@graphql-tools/schema": "^10.0.6",
"@lightninglabs/lnc-web": "^0.3.2-alpha",
"@noble/curves": "^1.6.0",
"@nostr-dev-kit/ndk": "^2.10.5",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@slack/web-api": "^7.6.0",

View File

@ -16,6 +16,7 @@ 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'
@ -49,7 +50,6 @@ ${ITEM_FULL_FIELDS}
photoId
ncomments
nposts
proportion
optional {
streak
@ -117,10 +117,9 @@ export default function Rewards ({ ssrData }) {
if (!dat) return <PageLoading />
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)
function EstimatedReward ({ rank }) {
const referrerReward = Math.floor(total * proportions[rank - 1] * 0.2)
const reward = Math.floor(total * proportions[rank - 1]) - referrerReward
return (
<div className='text-muted fst-italic'>

View File

@ -1,14 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[predecessorId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "predecessorId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Invoice.predecessorId_unique" ON "Invoice"("predecessorId");
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,93 +0,0 @@
CREATE OR REPLACE FUNCTION user_values(
min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
percentile_cutoff INTEGER DEFAULT 50,
each_upvote_portion FLOAT DEFAULT 4.0,
each_item_portion FLOAT DEFAULT 4.0,
handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}',
handicap_zap_mult FLOAT DEFAULT 0.3)
RETURNS TABLE (
t TIMESTAMP(3), id INTEGER, proportion FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, u."userId", u.total_proportion
FROM generate_series(min, max, ival) period(t),
LATERAL
(WITH item_ratios AS (
SELECT *,
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
FROM (
SELECT *,
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
FROM
"Item"
WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "weightedVotes" > 0
AND "deletedAt" IS NULL
AND NOT bio
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
) x
WHERE x.percentile <= percentile_cutoff
),
-- get top upvoters of top posts and comments
upvoter_islands AS (
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
"ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
- ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
FROM item_ratios
JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
WHERE act = 'TIP'
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
),
-- isolate contiguous upzaps from the same user on the same item so that when we take the log
-- of the upzaps it accounts for successive zaps and does not disproportionately reward them
-- quad root of the total tipped
upvoters AS (
SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at
FROM upvoter_islands
GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
),
-- the relative contribution of each upvoter to the post/comment
-- early component: 1/ln(early_rank + e - 1)
-- tipped component: how much they tipped relative to the total tipped for the item
-- multiplied by the relative rank of the item to the total items
-- multiplied by the trust of the user
upvoter_ratios AS (
SELECT "userId", sum((early_multiplier+tipped_ratio)*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE users.trust+0.1 END) as upvoter_ratio,
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
FROM (
SELECT *,
1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier,
tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
FROM upvoters
WHERE tipped > 2.1
) u
JOIN users on "userId" = users.id
GROUP BY "userId", "parentId" IS NULL
),
proportions AS (
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
FROM upvoter_ratios
WHERE upvoter_ratio > 0
UNION ALL
SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
FROM item_ratios
)
SELECT "userId", sum(proportions.proportion) AS total_proportion
FROM proportions
GROUP BY "userId"
HAVING sum(proportions.proportion) > 0.000001) u;
END;
$$;
REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today;
REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days;

View File

@ -904,41 +904,39 @@ model ItemMention {
}
model Invoice {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
confirmedIndex BigInt?
cancelled Boolean @default(false)
cancelledAt DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
predecessorId Int? @unique(map: "Invoice.predecessorId_unique")
predecessorInvoice Invoice? @relation("PredecessorInvoice", fields: [predecessorId], references: [id], onDelete: Cascade)
successorInvoice Invoice? @relation("PredecessorInvoice")
actionState InvoiceActionState?
actionType InvoiceActionType?
actionOptimistic Boolean?
actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
PollVote PollVote[]
PollBlindVote PollBlindVote[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
confirmedIndex BigInt?
cancelled Boolean @default(false)
cancelledAt DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
actionState InvoiceActionState?
actionType InvoiceActionType?
actionOptimistic Boolean?
actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
PollVote PollVote[]
PollBlindVote PollBlindVote[]
@@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index")

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
# test if every node can pay invoices from every other node
SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
# -e: exit on first failure | -x: print commands
set -ex
sndev cli lnd queryroutes $SN_LND_PUBKEY 1000
sndev cli lnd queryroutes $CLN_PUBKEY 1000
sndev cli sn_lnd queryroutes $LND_PUBKEY 1000
sndev cli sn_lnd queryroutes $CLN_PUBKEY 1000
# https://docs.corelightning.org/reference/lightning-getroute
sndev cli cln getroute $LND_PUBKEY 1000 0
sndev cli cln getroute $SN_LND_PUBKEY 1000 0

View File

@ -1,10 +1,9 @@
import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common'
export * from '@/wallets/blink'
export async function testSendPayment ({ apiKey, currency }, { logger, signal }) {
export async function testSendPayment ({ apiKey, currency }, { logger }) {
logger.info('trying to fetch ' + currency + ' wallet')
const scopes = await getScopes({ apiKey }, { signal })
const scopes = await getScopes(apiKey)
if (!scopes.includes(SCOPE_READ)) {
throw new Error('missing READ scope')
}
@ -13,48 +12,46 @@ export async function testSendPayment ({ apiKey, currency }, { logger, signal })
}
currency = currency ? currency.toUpperCase() : 'BTC'
await getWallet({ apiKey, currency }, { signal })
await getWallet(apiKey, currency)
logger.ok(currency + ' wallet found')
}
export async function sendPayment (bolt11, { apiKey, currency }, { signal }) {
const wallet = await getWallet({ apiKey, currency }, { signal })
return await payInvoice(bolt11, { apiKey, wallet }, { signal })
export async function sendPayment (bolt11, { apiKey, currency }) {
const wallet = await getWallet(apiKey, currency)
return await payInvoice(apiKey, wallet, bolt11)
}
async function payInvoice (bolt11, { apiKey, wallet }, { signal }) {
const out = await request({
apiKey,
query: `
mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
async function payInvoice (authToken, wallet, invoice) {
const walletId = wallet.id
const out = await request(authToken, `
mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
lnInvoicePaymentSend(input: $input) {
status
errors {
message
path
code
}
transaction {
settlementVia {
... on SettlementViaIntraLedger {
preImage
}
... on SettlementViaLn {
preImage
}
status
errors {
message
path
code
}
transaction {
settlementVia {
... on SettlementViaIntraLedger {
preImage
}
... on SettlementViaLn {
preImage
}
}
}
}
}
}`,
variables: {
input: {
paymentRequest: bolt11,
walletId: wallet.id
}
}
}, { signal })
`,
{
input: {
paymentRequest: invoice,
walletId
}
})
const status = out.data.lnInvoicePaymentSend.status
const errors = out.data.lnInvoicePaymentSend.errors
if (errors && errors.length > 0) {
@ -79,7 +76,7 @@ async function payInvoice (bolt11, { apiKey, wallet }, { signal }) {
// 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(bolt11, { apiKey, wallet }, { signal })
const txInfo = await getTxInfo(authToken, wallet, invoice)
// settled
if (txInfo.status === 'SUCCESS') {
if (!txInfo.preImage) throw new Error('no preimage')
@ -98,37 +95,36 @@ async function payInvoice (bolt11, { apiKey, wallet }, { signal }) {
throw new Error('unexpected error')
}
async function getTxInfo (bolt11, { apiKey, wallet }, { signal }) {
async function getTxInfo (authToken, wallet, invoice) {
const walletId = wallet.id
let out
try {
out = await request({
apiKey,
query: `
query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
me {
defaultAccount {
walletById(walletId: $walletId) {
transactionsByPaymentRequest(paymentRequest: $paymentRequest) {
status
direction
settlementVia {
out = await request(authToken, `
query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
me {
defaultAccount {
walletById(walletId: $walletId) {
transactionsByPaymentRequest(paymentRequest: $paymentRequest) {
status
direction
settlementVia {
... on SettlementViaIntraLedger {
preImage
preImage
}
... on SettlementViaLn {
preImage
preImage
}
}
}
}
}
}
}`,
variables: {
paymentRequest: bolt11,
walletId: wallet.Id
}
}
}, { signal })
`,
{
paymentRequest: invoice,
walletId
})
} catch (e) {
// something went wrong during the query,
// maybe the connection was lost, so we just return

View File

@ -1,4 +1,3 @@
import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
@ -8,42 +7,38 @@ export const SCOPE_READ = 'READ'
export const SCOPE_WRITE = 'WRITE'
export const SCOPE_RECEIVE = 'RECEIVE'
export async function getWallet ({ apiKey, currency }, { signal }) {
const out = await request({
apiKey,
query: `
export async function getWallet (authToken, currency) {
const out = await request(authToken, `
query me {
me {
defaultAccount {
wallets {
id
walletCurrency
}
me {
defaultAccount {
wallets {
id
walletCurrency
}
}
}
}
}`
}, { signal })
}
`, {})
const wallets = out.data.me.defaultAccount.wallets
for (const wallet of wallets) {
if (wallet.walletCurrency === currency) {
return wallet
}
}
throw new Error(`wallet ${currency} not found`)
}
export async function request ({ apiKey, query, variables = {} }, { signal }) {
const res = await fetchWithTimeout(galoyBlinkUrl, {
export async function request (authToken, query, variables = {}) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': apiKey
'X-API-KEY': authToken
},
body: JSON.stringify({ query, variables }),
signal
})
body: JSON.stringify({ query, variables })
}
const res = await fetch(galoyBlinkUrl, options)
assertResponseOk(res)
assertContentTypeJson(res)
@ -51,16 +46,14 @@ export async function request ({ apiKey, query, variables = {} }, { signal }) {
return res.json()
}
export async function getScopes ({ apiKey }, { signal }) {
const out = await request({
apiKey,
query: `
query scopes {
export async function getScopes (authToken) {
const out = await request(authToken, `
query scopes {
authorization {
scopes
scopes
}
}`
}, { signal })
}
`, {})
const scopes = out?.data?.authorization?.scopes
return scopes || []
}

View File

@ -1,9 +1,10 @@
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 }, { signal }) {
const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal })
export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) {
const scopes = await getScopes(apiKeyRecv)
if (!scopes.includes(SCOPE_READ)) {
throw new Error('missing READ scope')
}
@ -14,50 +15,47 @@ export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal
throw new Error('missing RECEIVE scope')
}
const timeout = 15_000
currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC'
return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal })
return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }), timeout)
}
export async function createInvoice (
{ msats, description, expiry },
{ apiKeyRecv: apiKey, currencyRecv: currency },
{ signal }) {
currency = currency ? currency.toUpperCase() : 'BTC'
{ apiKeyRecv, currencyRecv }) {
currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC'
const wallet = await getWallet({ apiKey, currency }, { signal })
const wallet = await getWallet(apiKeyRecv, currencyRecv)
if (currency !== 'BTC') {
throw new Error('unsupported currency ' + currency)
if (currencyRecv !== 'BTC') {
throw new Error('unsupported currency ' + currencyRecv)
}
const out = await request({
apiKey,
query: `
mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
lnInvoiceCreate(input: $input) {
invoice {
paymentRequest
}
errors {
message
}
const mutation = `
mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
lnInvoiceCreate(input: $input) {
invoice {
paymentRequest
}
errors {
message
}
}
}
}`,
variables: {
input: {
amount: msatsToSats(msats),
expiresIn: Math.floor(expiry / 60) || 1,
memo: description,
walletId: wallet.id
}
}
}, { signal })
`
const out = await request(apiKeyRecv, mutation, {
input: {
amount: msatsToSats(msats),
expiresIn: Math.floor(expiry / 60) || 1,
memo: description,
walletId: wallet.id
}
})
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(', '))
}
return res.invoice.paymentRequest
const invoice = res.invoice.paymentRequest
return invoice
}

View File

@ -2,26 +2,23 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln'
export * from '@/wallets/cln'
export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => {
return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal })
export const testCreateInvoice = async ({ socket, rune, cert }) => {
return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert })
}
export const createInvoice = async (
{ msats, description, expiry },
{ socket, rune, cert },
{ signal }) => {
const inv = await clnCreateInvoice(
{
msats,
description,
expiry
},
{
socket,
rune,
cert
},
{ signal })
{ msats, description, descriptionHash, expiry },
{ socket, rune, cert }
) => {
const inv = await clnCreateInvoice({
socket,
rune,
cert,
description,
descriptionHash,
msats,
expiry
})
return inv.bolt11
}

View File

@ -8,8 +8,6 @@ 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()
@ -39,28 +37,17 @@ export function useWalletConfigurator (wallet) {
let serverConfig = serverWithShared
if (canSend({ def: wallet.def, config: clientConfig })) {
try {
let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true })
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 })
if (transformedConfig) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
if (wallet.def.testSendPayment && validateLightning) {
transformedConfig = await withTimeout(
wallet.def.testSendPayment(clientConfig, {
logger,
signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
}),
WALLET_SEND_PAYMENT_TIMEOUT_MS
)
if (transformedConfig) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
// validate again to ensure generated fields are valid
await validateWallet(wallet.def, clientConfig)
}
} catch (err) {
logger.error(err.message)
throw err
// validate again to ensure generated fields are valid
await validateWallet(wallet.def, clientConfig)
}
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
const transformedConfig = await validateWallet(wallet.def, serverConfig)
@ -84,52 +71,33 @@ export function useWalletConfigurator (wallet) {
}, [me?.id, wallet.def.name, reloadLocalWallets])
const save = useCallback(async (newConfig, validateLightning = true) => {
const { clientWithShared: oldClientConfig } = siftConfig(wallet.def.fields, wallet.config)
const { clientConfig: newClientConfig, serverConfig: newServerConfig } = await _validate(newConfig, validateLightning)
const oldCanSend = canSend({ def: wallet.def, config: oldClientConfig })
const newCanSend = canSend({ def: wallet.def, config: newClientConfig })
const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning)
// if vault is active, encrypt and send to server regardless of wallet type
if (isActive) {
await _saveToServer(newServerConfig, newClientConfig, validateLightning)
await _saveToServer(serverConfig, clientConfig, validateLightning)
await _detachFromLocal()
} else {
if (newCanSend) {
await _saveToLocal(newClientConfig)
if (canSend({ def: wallet.def, config: clientConfig })) {
await _saveToLocal(clientConfig)
} else {
// if it previously had a client config, remove it
await _detachFromLocal()
}
if (canReceive({ def: wallet.def, config: newServerConfig })) {
await _saveToServer(newServerConfig, newClientConfig, validateLightning)
if (canReceive({ def: wallet.def, config: serverConfig })) {
await _saveToServer(serverConfig, clientConfig, validateLightning)
} else if (wallet.config.id) {
// we previously had a server config
if (wallet.vaultEntries.length > 0) {
// we previously had a server config with vault entries, save it
await _saveToServer(newServerConfig, newClientConfig, validateLightning)
await _saveToServer(serverConfig, clientConfig, validateLightning)
} else {
// we previously had a server config without vault entries, remove it
await _detachFromServer()
}
}
}
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,
}, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate,
_detachFromLocal, _detachFromServer])
const detach = useCallback(async () => {
@ -144,9 +112,7 @@ export function useWalletConfigurator (wallet) {
// if vault is not active and has a client config, delete from local storage
await _detachFromLocal()
}
logger.info('details for sending deleted')
}, [logger, isActive, _detachFromServer, _detachFromLocal])
}, [isActive, _detachFromServer, _detachFromLocal])
return { save, detach }
}

View File

@ -47,14 +47,6 @@ export class WalletSenderError extends WalletPaymentError {
}
}
export class WalletReceiverError extends WalletPaymentError {
constructor (invoice) {
super(`payment forwarding failed for invoice ${invoice.hash}`)
this.name = 'WalletReceiverError'
this.invoice = invoice
}
}
export class WalletsNotAvailableError extends WalletConfigurationError {
constructor () {
super('no wallet available')

View File

@ -220,7 +220,7 @@ export function useWallet (name) {
export function useSendWallets () {
const { wallets } = useWallets()
// return all enabled wallets that are available and can send
// return the first enabled wallet that is available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))

View File

@ -1,20 +1,18 @@
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 }, { signal }) => {
return await createInvoice({ msats: 1000 }, { address }, { signal })
export const testCreateInvoice = async ({ address }) => {
return await createInvoice({ msats: 1000 }, { address })
}
export const createInvoice = async (
{ msats, description },
{ address },
{ signal }
{ address }
) => {
const { callback, commentAllowed } = await lnAddrOptions(address, { signal })
const { callback, commentAllowed } = await lnAddrOptions(address)
const callbackUrl = new URL(callback)
// most lnurl providers suck nards so we have to floor to nearest sat
@ -27,7 +25,7 @@ export const createInvoice = async (
}
// call callback with amount and conditionally comment
const res = await fetchWithTimeout(callbackUrl.toString(), { signal })
const res = await fetch(callbackUrl.toString())
assertResponseOk(res)
assertContentTypeJson(res)

View File

@ -1,23 +1,22 @@
import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson } from '@/lib/url'
export * from '@/wallets/lnbits'
export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) {
export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) {
logger.info('trying to fetch wallet')
url = url.replace(/\/+$/, '')
await getWallet({ url, adminKey, invoiceKey }, { signal })
await getWallet({ url, adminKey, invoiceKey })
logger.ok('wallet found')
}
export async function sendPayment (bolt11, { url, adminKey }, { signal }) {
export async function sendPayment (bolt11, { url, adminKey }) {
url = url.replace(/\/+$/, '')
const response = await postPayment(bolt11, { url, adminKey }, { signal })
const response = await postPayment(bolt11, { url, adminKey })
const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal })
const checkResponse = await getPayment(response.payment_hash, { url, adminKey })
if (!checkResponse.preimage) {
throw new Error('No preimage')
}
@ -25,7 +24,7 @@ export async function sendPayment (bolt11, { url, adminKey }, { signal }) {
return checkResponse.preimage
}
async function getWallet ({ url, adminKey, invoiceKey }, { signal }) {
async function getWallet ({ url, adminKey, invoiceKey }) {
const path = '/api/v1/wallet'
const headers = new Headers()
@ -33,7 +32,7 @@ async function getWallet ({ url, adminKey, invoiceKey }, { signal }) {
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey || invoiceKey)
const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal })
const res = await fetch(url + path, { method: 'GET', headers })
assertContentTypeJson(res)
if (!res.ok) {
@ -45,7 +44,7 @@ async function getWallet ({ url, adminKey, invoiceKey }, { signal }) {
return wallet
}
async function postPayment (bolt11, { url, adminKey }, { signal }) {
async function postPayment (bolt11, { url, adminKey }) {
const path = '/api/v1/payments'
const headers = new Headers()
@ -55,7 +54,7 @@ async function postPayment (bolt11, { url, adminKey }, { signal }) {
const body = JSON.stringify({ bolt11, out: true })
const res = await fetchWithTimeout(url + path, { method: 'POST', headers, body, signal })
const res = await fetch(url + path, { method: 'POST', headers, body })
assertContentTypeJson(res)
if (!res.ok) {
@ -67,7 +66,7 @@ async function postPayment (bolt11, { url, adminKey }, { signal }) {
return payment
}
async function getPayment (paymentHash, { url, adminKey }, { signal }) {
async function getPayment (paymentHash, { url, adminKey }) {
const path = `/api/v1/payments/${paymentHash}`
const headers = new Headers()
@ -75,7 +74,7 @@ async function getPayment (paymentHash, { url, adminKey }, { signal }) {
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey)
const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal })
const res = await fetch(url + path, { method: 'GET', headers })
assertContentTypeJson(res)
if (!res.ok) {

View File

@ -1,5 +1,3 @@
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'
@ -7,14 +5,13 @@ import fetch from 'cross-fetch'
export * from '@/wallets/lnbits'
export async function testCreateInvoice ({ url, invoiceKey }, { signal }) {
return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal })
export async function testCreateInvoice ({ url, invoiceKey }) {
return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
{ url, invoiceKey },
{ signal }) {
{ url, invoiceKey }) {
const path = '/api/v1/payments'
const headers = new Headers()
@ -41,23 +38,12 @@ export async function createInvoice (
hostname = 'lnbits:5000'
}
let res
try {
res = await fetch(`${agent.protocol}//${hostname}${path}`, {
method: 'POST',
headers,
agent,
body,
signal
})
} catch (err) {
if (err.name === 'AbortError') {
// XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually.
// see https://github.com/node-fetch/node-fetch/issues/1462
throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS)
}
throw err
}
const res = await fetch(`${agent.protocol}//${hostname}${path}`, {
method: 'POST',
headers,
agent,
body
})
assertContentTypeJson(res)
if (!res.ok) {

View File

@ -1,16 +1,21 @@
import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc'
import { nwcCall, supportedMethods } from '@/wallets/nwc'
export * from '@/wallets/nwc'
export async function testSendPayment ({ nwcUrl }, { signal }) {
const supported = await supportedMethods(nwcUrl, { signal })
export async function testSendPayment ({ nwcUrl }, { logger }) {
const timeout = 15_000
const supported = await supportedMethods(nwcUrl, { logger, timeout })
if (!supported.includes('pay_invoice')) {
throw new Error('pay_invoice not supported')
}
}
export async function sendPayment (bolt11, { nwcUrl }, { signal }) {
const nwc = await getNwc(nwcUrl, { signal })
// TODO: support AbortSignal
const result = await nwcTryRun(() => nwc.payInvoice(bolt11))
export async function sendPayment (bolt11, { nwcUrl }, { logger }) {
const result = await nwcCall({
nwcUrl,
method: 'pay_invoice',
params: { invoice: bolt11 }
},
{ logger })
return result.preimage
}

View File

@ -1,10 +1,7 @@
import Nostr from '@/lib/nostr'
import { string } from '@/lib/yup'
import { Relay } from '@/lib/nostr'
import { parseNwcUrl } from '@/lib/url'
import { NDKNwc } from '@nostr-dev-kit/ndk'
import { TimeoutError } from '@/lib/time'
const NWC_CONNECT_TIMEOUT_MS = 15_000
import { string } from '@/lib/yup'
import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
export const name = 'nwc'
export const walletType = 'NWC'
@ -36,49 +33,61 @@ export const card = {
subtitle: 'use Nostr Wallet Connect for payments'
}
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
})
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}`)
// TODO: support AbortSignal
try {
await nwc.blockUntilReady(NWC_CONNECT_TIMEOUT_MS)
} catch (err) {
if (err.message === 'Timeout') {
throw new TimeoutError(NWC_CONNECT_TIMEOUT_MS)
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`)
}
throw err
}
return nwc
}
logger?.ok(`${method} response received`)
/**
* 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
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}`)
}
}
export async function supportedMethods (nwcUrl, { signal }) {
const nwc = await getNwc(nwcUrl, { signal })
// TODO: support AbortSignal
const result = await nwcTryRun(() => nwc.getInfo())
export async function supportedMethods (nwcUrl, { logger, timeout } = {}) {
const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout })
return result.methods
}

View File

@ -1,8 +1,11 @@
import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc'
import { withTimeout } from '@/lib/time'
import { nwcCall, supportedMethods } from '@/wallets/nwc'
export * from '@/wallets/nwc'
export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) {
const supported = await supportedMethods(nwcUrlRecv, { signal })
export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) {
const timeout = 15_000
const supported = await supportedMethods(nwcUrlRecv, { logger, timeout })
const supports = (method) => supported.includes(method)
@ -17,12 +20,20 @@ export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) {
}
}
return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal })
return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { logger }), timeout)
}
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 }))
export async function createInvoice (
{ msats, description, expiry },
{ nwcUrlRecv }, { logger }) {
const result = await nwcCall({
nwcUrl: nwcUrlRecv,
method: 'make_invoice',
params: {
amount: msats,
description,
expiry
}
}, { logger })
return result.invoice
}

View File

@ -2,19 +2,17 @@ import { useCallback } from 'react'
import { useSendWallets } from '@/wallets'
import { formatSats } from '@/lib/format'
import useInvoice from '@/components/use-invoice'
import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
} 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 }) => {
@ -26,71 +24,44 @@ export function useWalletPayment () {
throw new WalletsNotAvailableError()
}
for (let i = 0; i < wallets.length; i++) {
const wallet = wallets[i]
const logger = loggerFactory(wallet)
const { bolt11 } = latestInvoice
for (const [i, wallet] of wallets.entries()) {
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.
walletPromise.catch(reject)
pollPromise.then(resolve).catch(reject)
sendPayment(wallet, latestInvoice).catch(reject)
controller.wait(waitFor)
.then(resolve)
.catch(reject)
})
} catch (err) {
let paymentError = err
const message = `payment failed: ${paymentError.reason ?? paymentError.message}`
// 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)
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
// is there another wallet to try?
const lastAttempt = i === wallets.length - 1
if (!lastAttempt) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}
}
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 })
// TODO: receiver fallbacks
//
// if payment failed because of the receiver, we should use the same wallet again.
// if (err instanceof ReceiverError) { ... }
// try next wallet if the payment failed because of the wallet
// and not because it expired or was canceled
if (err instanceof WalletError) {
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
continue
}
if (paymentError instanceof WalletPaymentError) {
// if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry.
await invoiceHelper.cancel(latestInvoice)
}
// only create a new invoice if we will try to pay with a wallet again
const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1
if (retry) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}
aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice)
continue
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
throw err
} finally {
controller.stop()
}
@ -140,7 +111,11 @@ function invoiceController (inv, isInvoice) {
}
function useSendPayment () {
return useCallback(async (wallet, logger, invoice) => {
const factory = useWalletLoggerFactory()
return useCallback(async (wallet, invoice) => {
const logger = factory(wallet)
if (!wallet.config.enabled) {
throw new WalletNotEnabledError(wallet.def.name)
}
@ -153,17 +128,12 @@ function useSendPayment () {
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
try {
const preimage = await withTimeout(
wallet.def.sendPayment(bolt11, wallet.config, {
logger,
signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
}),
WALLET_SEND_PAYMENT_TIMEOUT_MS)
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) {
// we don't log the error here since we want to handle receiver errors separately
const message = err.message || err.toString?.()
logger.error(`payment failed: ${message}`, { bolt11 })
throw new WalletSenderError(wallet.def.name, invoice, message)
}
}, [])
}, [factory])
}

View File

@ -1,9 +1,8 @@
import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/phoenixd'
export async function testSendPayment (config, { logger, signal }) {
export async function testSendPayment (config, { logger }) {
// TODO:
// Not sure which endpoint to call to test primary password
// see https://phoenix.acinq.co/server/api
@ -11,7 +10,7 @@ export async function testSendPayment (config, { logger, signal }) {
}
export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) {
export async function sendPayment (bolt11, { url, primaryPassword }) {
// https://phoenix.acinq.co/server/api#pay-bolt11-invoice
const path = '/payinvoice'
@ -22,11 +21,10 @@ export async function sendPayment (bolt11, { url, primaryPassword }, { signal })
const body = new URLSearchParams()
body.append('invoice', bolt11)
const res = await fetchWithTimeout(url + path, {
const res = await fetch(url + path, {
method: 'POST',
headers,
body,
signal
body
})
assertResponseOk(res)

View File

@ -1,20 +1,17 @@
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 }, { signal }) {
export async function testCreateInvoice ({ url, secondaryPassword }) {
return await createInvoice(
{ msats: 1000, description: 'SN test invoice', expiry: 1 },
{ url, secondaryPassword },
{ signal })
{ url, secondaryPassword })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
{ url, secondaryPassword },
{ signal }
{ url, secondaryPassword }
) {
// https://phoenix.acinq.co/server/api#create-bolt11-invoice
const path = '/createinvoice'
@ -27,11 +24,10 @@ export async function createInvoice (
body.append('description', description)
body.append('amountSat', msatsToSats(msats))
const res = await fetchWithTimeout(url + path, {
const res = await fetch(url + path, {
method: 'POST',
headers,
body,
signal
body
})
assertResponseOk(res)

View File

@ -15,8 +15,8 @@ import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format'
import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { timeoutSignal, withTimeout } from '@/lib/time'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { 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 }, { predecessorId, models }) {
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
// get the wallets in order of priority
const wallets = await getInvoiceableWallets(userId, { predecessorId, models })
const wallets = await getInvoiceableWallets(userId, { 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 },
{ predecessorId, models, me, lnd }) {
{ 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
}, { predecessorId, models })
}, { models })
logger = walletLogger({ wallet, models })
bolt11 = invoice
@ -110,48 +110,18 @@ export async function createWrappedInvoice (userId,
}
}
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`
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' }
]
})
const walletsWithDefs = wallets.map(wallet => {
const w = walletDefs.find(w => w.walletType === wallet.type)
@ -201,9 +171,6 @@ async function walletCreateInvoice ({ wallet, def }, {
expiry
},
wallet.wallet,
{
logger,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}
), WALLET_CREATE_INVOICE_TIMEOUT_MS)
{ logger }
), 10_000)
}

View File

@ -1,5 +1,6 @@
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
@ -39,19 +40,18 @@ export async function earn ({ name }) {
/*
How earnings (used to) work:
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 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 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 stackers by relative value, and 10% each to their forever and one day referrers
Now: 80% of earnings go to top 100 stackers by 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 [, earner] of earners.entries()) {
for (const [i, 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(earner.proportion * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings
const earnerEarnings = Math.floor(parseFloat(proportions[i] * 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 > 1000) {
if (earnerEarnings > 0) {
stmts.push(...earnStmts({
msats: earnerEarnings,
userId: earner.userId,
@ -140,7 +140,7 @@ export async function earn ({ name }) {
}
}
if (earner.foreverReferrerId && foreverReferrerEarnings > 1000) {
if (earner.foreverReferrerId && foreverReferrerEarnings > 0) {
stmts.push(...earnStmts({
msats: foreverReferrerEarnings,
userId: earner.foreverReferrerId,
@ -153,7 +153,7 @@ export async function earn ({ name }) {
oneDayReferrerEarnings += foreverReferrerEarnings
}
if (earner.oneDayReferrerId && oneDayReferrerEarnings > 1000) {
if (earner.oneDayReferrerId && oneDayReferrerEarnings > 0) {
stmts.push(...earnStmts({
msats: oneDayReferrerEarnings,
userId: earner.oneDayReferrerId,

View File

@ -38,12 +38,6 @@ import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
// WebSocket polyfill
import ws from 'isomorphic-ws'
if (typeof WebSocket === 'undefined') {
global.WebSocket = ws
}
async function work () {
const boss = new PgBoss(process.env.DATABASE_URL)
const models = createPrisma({

View File

@ -1,4 +1,5 @@
import Nostr from '@/lib/nostr'
import { signId, calculateId, getPublicKey } from 'nostr'
import { Relay } from '@/lib/nostr'
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
@ -39,18 +40,26 @@ 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)
const signer = Nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY })
await Nostr.publish(e, {
relays,
signer,
timeout: 1000
})
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()
}
})
)
} catch (e) {
console.log(e)
}

View File

@ -1,7 +1,7 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd'
import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet'
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { 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,7 +270,6 @@ 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)
}
@ -317,11 +316,13 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
}, { models, lnd, boss })
if (transitionedInvoice) {
const { bolt11, msatsPaid } = transitionedInvoice.invoiceForward.withdrawl
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 logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models })
logger.ok(
`↙ payment received: ${formatSats(msatsToSats(Number(msatsPaid)))}`,
`↙ payment received: ${formatSats(msatsToSats(received))}`,
{
bolt11,
preimage: transitionedInvoice.preimage