Compare commits

...

6 Commits

Author SHA1 Message Date
k00b a495c421ce select first status=null on withdrawal check 2024-08-19 10:10:34 -05:00
k00b 2ff839f3a5 check invoice after transitioning to cancel 2024-08-18 18:03:01 -05:00
Keyan 3264601dc6
add priority:low to readme 2024-08-18 17:30:25 -05:00
ekzyis 06b661625c
Use custom relay API (#1302)
* Use custom relay API

Relay from nostr-tools was cumbersome to use. This custom abstraction over window.WebSocket makes interacting with nostr relays easier.

* Use variables for nostr message parts

* Fix NWC save

* Use try/finally

* Refactor crossposting code

* use custom replay API
* simplify callWithTimeout

* Use isomorphic-ws for nip57 zap receipts

* Use async map

* Reject with timeout error

* Move time functions into lib/time

* Remove outdated comment regarding relay.close()
2024-08-18 17:28:39 -05:00
ekzyis ccbc28322e
Add wasm-unsafe-eval to CSP for LNC (#1313) 2024-08-18 17:20:46 -05:00
k00b df7baf4d7c don't filter freebies/outlaws on profiles + fix ~sub/recent/all 2024-08-18 16:43:19 -05:00
13 changed files with 253 additions and 188 deletions

View File

@ -230,6 +230,7 @@ _Due to Rule 3, make sure that you mark your PR as a draft when you create it an
| tag | multiplier | | tag | multiplier |
| ----------------- | ---------- | | ----------------- | ---------- |
| `priority:low` | 0.5 |
| `priority:medium` | 1.5 | | `priority:medium` | 1.5 |
| `priority:high` | 2 | | `priority:high` | 2 |
| `priority:urgent` | 3 | | `priority:urgent` | 3 |

View File

@ -158,6 +158,8 @@ export async function retryPaidAction (actionType, args, context) {
const { models, me } = context const { models, me } = context
const { invoiceId } = args const { invoiceId } = args
console.log('retryPaidAction', actionType, args)
const action = paidActions[actionType] const action = paidActions[actionType]
if (!action) { if (!action) {
throw new Error(`retryPaidAction - invalid action type ${actionType}`) throw new Error(`retryPaidAction - invalid action type ${actionType}`)

View File

@ -202,7 +202,10 @@ export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
const subClause = (sub, num, table, me, showNsfw) => { const subClause = (sub, num, table, me, showNsfw) => {
// Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub // Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub
if (sub) { return `${table ? `"${table}".` : ''}"subName" = $${num}::CITEXT` } if (sub) {
const tables = [...new Set(['Item', table])].map(t => `"${t}".`)
return `(${tables.map(t => `${t}"subName" = $${num}::CITEXT`).join(' OR ')})`
}
if (!me) { return HIDE_NSFW_CLAUSE } if (!me) { return HIDE_NSFW_CLAUSE }
@ -346,7 +349,6 @@ export default {
${whereClause( ${whereClause(
`"${table}"."userId" = $3`, `"${table}"."userId" = $3`,
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type),
nsfwClause(showNsfw), nsfwClause(showNsfw),
typeClause(type), typeClause(type),
whenClause(when || 'forever', table))} whenClause(when || 'forever', table))}

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/router'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import BackIcon from '@/svgs/arrow-left-line.svg' import BackIcon from '@/svgs/arrow-left-line.svg'
import styles from './lightning-auth.module.css' import styles from './lightning-auth.module.css'
import { callWithTimeout } from '@/lib/nostr' import { callWithTimeout } from '@/lib/time'
function ExtensionError ({ message, details }) { function ExtensionError ({ message, details }) {
return ( return (

View File

@ -1,7 +1,8 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useToast } from './toast' import { useToast } from './toast'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { DEFAULT_CROSSPOSTING_RELAYS, crosspost, callWithTimeout } 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 { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
import { SETTINGS } from '@/fragments/users' import { SETTINGS } from '@/fragments/users'
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items' import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'

View File

@ -1,5 +1,7 @@
import { bech32 } from 'bech32' import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import WebSocket from 'isomorphic-ws'
import { callWithTimeout, withTimeout } from '@/lib/time'
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/ export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/ export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
@ -15,6 +17,149 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://relay.mutinywallet.com/' 'wss://relay.mutinywallet.com/'
] ]
export class Relay {
constructor (relayUrl) {
const ws = new WebSocket(relayUrl)
ws.onmessage = function (msg) {
const [type, notice] = JSON.parse(msg.data)
if (type === 'NOTICE') {
console.log('relay notice:', notice)
}
}
ws.onerror = function (err) {
console.error('websocket error: ' + err)
this.error = err
}
this.ws = ws
}
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 {
return await withTimeout(checkPromise, timeout)
} finally {
clearInterval(interval)
}
}
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) => {
ws.send(JSON.stringify(['EVENT', event]))
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}`))
}
}
ws.addEventListener('message', listener)
})
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.randomUUID()
ws.send(JSON.stringify(['REQ', id, ...filter]))
const events = []
let eose = false
listener = function onmessage (msg) {
const [type, eventId, event] = JSON.parse(msg.data)
if (eventId !== 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)
})
try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
}
}
}
export function hexToBech32 (hex, prefix = 'npub') { export function hexToBech32 (hex, prefix = 'npub') {
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex'))) return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
} }
@ -36,51 +181,10 @@ export function nostrZapDetails (zap) {
return { npub, content, note } return { npub, content, note }
} }
async function publishNostrEvent (signedEvent, relay) { async function publishNostrEvent (signedEvent, relayUrl) {
return new Promise((resolve, reject) => {
const timeout = 3000 const timeout = 3000
const wsRelay = new window.WebSocket(relay) const relay = await Relay.connect(relayUrl, { timeout })
let timer await relay.publish(signedEvent, { timeout })
let isMessageSentSuccessfully = false
function timedout () {
clearTimeout(timer)
wsRelay.close()
reject(new Error(`relay timeout for ${relay}`))
}
timer = setTimeout(timedout, timeout)
wsRelay.onopen = function () {
clearTimeout(timer)
timer = setTimeout(timedout, timeout)
wsRelay.send(JSON.stringify(['EVENT', signedEvent]))
}
wsRelay.onmessage = function (msg) {
const m = JSON.parse(msg.data)
if (m[0] === 'OK') {
isMessageSentSuccessfully = true
clearTimeout(timer)
wsRelay.close()
console.log('Successfully sent event to', relay)
resolve()
}
}
wsRelay.onerror = function (error) {
clearTimeout(timer)
console.log(error)
reject(new Error(`relay error: Failed to send to ${relay}`))
}
wsRelay.onclose = function () {
clearTimeout(timer)
if (!isMessageSentSuccessfully) {
reject(new Error(`relay error: Failed to send to ${relay}`))
}
}
})
} }
export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) { export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
@ -118,13 +222,3 @@ export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
return { error } return { error }
} }
} }
export function callWithTimeout (targetFunction, timeoutMs) {
return new Promise((resolve, reject) => {
Promise.race([
targetFunction(),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timeouted after ' + timeoutMs + ' ms waiting for extension')), timeoutMs))
]).then(resolve)
.catch(reject)
})
}

View File

@ -127,3 +127,20 @@ function tzOffset (tz) {
const targetOffsetHours = (date.getTime() - targetDate.getTime()) / 1000 / 60 / 60 const targetOffsetHours = (date.getTime() - targetDate.getTime()) / 1000 / 60 / 60
return targetOffsetHours return targetOffsetHours
} }
function timeoutPromise (timeout) {
return new Promise((resolve, reject) => {
// if no timeout is specified, never settle
if (!timeout) return
setTimeout(() => reject(new Error('timeout')), timeout)
})
}
export async function withTimeout (promise, timeout) {
return await Promise.race([promise, timeoutPromise(timeout)])
}
export async function callWithTimeout (fn, timeout) {
return await Promise.race([fn(), timeoutPromise(timeout)])
}

View File

@ -91,7 +91,7 @@ export function middleware (request) {
// Using nonces and strict-dynamic deploys a strict CSP. // Using nonces and strict-dynamic deploys a strict CSP.
// see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy. // see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy.
// Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline // Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline
`script-src 'self' 'unsafe-inline' 'nonce-${nonce}' 'strict-dynamic' https:` + devScriptSrc, `script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' 'nonce-${nonce}' 'strict-dynamic' https:` + devScriptSrc,
// unsafe-inline for styles is not ideal but okay if script-src is using nonces // unsafe-inline for styles is not ideal but okay if script-src is using nonces
"style-src 'self' a.stacker.news 'unsafe-inline'", "style-src 'self' a.stacker.news 'unsafe-inline'",
"manifest-src 'self'", "manifest-src 'self'",

9
package-lock.json generated
View File

@ -41,6 +41,7 @@
"graphql-scalar": "^0.1.0", "graphql-scalar": "^0.1.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"isomorphic-ws": "^5.0.0",
"ln-service": "^57.1.3", "ln-service": "^57.1.3",
"macaroon": "^3.0.4", "macaroon": "^3.0.4",
"mathjs": "^11.11.2", "mathjs": "^11.11.2",
@ -10548,6 +10549,14 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true "dev": true
}, },
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/isstream": { "node_modules/isstream": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",

View File

@ -46,6 +46,7 @@
"graphql-scalar": "^0.1.0", "graphql-scalar": "^0.1.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"isomorphic-ws": "^5.0.0",
"ln-service": "^57.1.3", "ln-service": "^57.1.3",
"macaroon": "^3.0.4", "macaroon": "^3.0.4",
"mathjs": "^11.11.2", "mathjs": "^11.11.2",

View File

@ -1,5 +1,6 @@
import { parseNwcUrl } from '@/lib/url' import { parseNwcUrl } from '@/lib/url'
import { Relay, finalizeEvent, nip04 } from 'nostr-tools' import { finalizeEvent, nip04 } from 'nostr-tools'
import { Relay } from '@/lib/nostr'
export * from 'wallets/nwc' export * from 'wallets/nwc'
@ -7,112 +8,67 @@ export async function testConnectClient ({ nwcUrl }, { logger }) {
const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl) const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl)
logger.info(`requesting info event from ${relayUrl}`) logger.info(`requesting info event from ${relayUrl}`)
const relay = await Relay
.connect(relayUrl) const relay = await Relay.connect(relayUrl)
.catch(() => {
// NOTE: passed error is undefined for some reason
const msg = `failed to connect to ${relayUrl}`
logger.error(msg)
throw new Error(msg)
})
logger.ok(`connected to ${relayUrl}`) logger.ok(`connected to ${relayUrl}`)
try { try {
await new Promise((resolve, reject) => { const [info] = await relay.fetch([{
let found = false
const sub = relay.subscribe([
{
kinds: [13194], kinds: [13194],
authors: [walletPubkey] authors: [walletPubkey]
} }])
], {
onevent (event) { if (info) {
found = true
logger.ok(`received info event from ${relayUrl}`) logger.ok(`received info event from ${relayUrl}`)
resolve(event) } else {
}, throw new Error('info event not found')
onclose (reason) {
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
// only log if not closed by us (caller)
const msg = 'connection closed: ' + (reason || 'unknown reason')
logger.error(msg)
reject(new Error(msg))
} }
},
oneose () {
if (!found) {
const msg = 'EOSE received without info event'
logger.error(msg)
reject(new Error(msg))
}
sub?.close()
}
})
})
} finally { } finally {
// For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state' relay?.close()
// even though relay connection is still open here logger.info(`closed connection to ${relayUrl}`)
relay?.close()?.catch()
if (relay) logger.info(`closed connection to ${relayUrl}`)
} }
} }
export async function sendPayment (bolt11, { nwcUrl }, { logger }) { export async function sendPayment (bolt11, { nwcUrl }, { logger }) {
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
const relay = await Relay.connect(relayUrl).catch(() => { const relay = await Relay.connect(relayUrl)
// NOTE: passed error is undefined for some reason
throw new Error(`failed to connect to ${relayUrl}`)
})
logger.ok(`connected to ${relayUrl}`) logger.ok(`connected to ${relayUrl}`)
try { try {
const ret = await new Promise(function (resolve, reject) {
(async function () {
const payload = { const payload = {
method: 'pay_invoice', method: 'pay_invoice',
params: { invoice: bolt11 } params: { invoice: bolt11 }
} }
const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) const encrypted = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
const request = finalizeEvent({ const request = finalizeEvent({
kind: 23194, kind: 23194,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['p', walletPubkey]], tags: [['p', walletPubkey]],
content content: encrypted
}, secret) }, secret)
await relay.publish(request) await relay.publish(request)
const filter = { const [response] = await relay.fetch([{
kinds: [23195], kinds: [23195],
authors: [walletPubkey], authors: [walletPubkey],
'#e': [request.id] '#e': [request.id]
}])
if (!response) {
throw new Error('no response')
} }
relay.subscribe([filter], {
async onevent (response) { const decrypted = await nip04.decrypt(secret, walletPubkey, response.content)
try { const content = JSON.parse(decrypted)
const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
if (content.error) return reject(new Error(content.error.message)) if (content.error) throw new Error(content.error.message)
if (content.result) return resolve({ preimage: content.result.preimage }) if (content.result) return { preimage: content.result.preimage }
} catch (err) {
return reject(err) throw new Error('invalid response')
}
},
onclose (reason) {
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
// only log if not closed by us (caller)
const msg = 'connection closed: ' + (reason || 'unknown reason')
reject(new Error(msg))
}
}
})
})().catch(reject)
})
return ret
} finally { } finally {
// For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state' relay?.close()
// even though relay connection is still open here logger.info(`closed connection to ${relayUrl}`)
relay?.close()?.catch()
if (relay) logger.info(`closed connection to ${relayUrl}`)
} }
} }

View File

@ -1,5 +1,6 @@
import { getInvoice } from 'ln-service' import { getInvoice } from 'ln-service'
import { Relay, signId, calculateId, getPublicKey } from 'nostr' import { signId, calculateId, getPublicKey } from 'nostr'
import { Relay } from '@/lib/nostr'
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
@ -50,31 +51,12 @@ export async function nip57 ({ data: { hash }, boss, lnd, models }) {
console.log('zap note', e, relays) console.log('zap note', e, relays)
await Promise.allSettled( await Promise.allSettled(
relays.map(r => new Promise((resolve, reject) => { relays.map(async r => {
const timeout = 1000 const timeout = 1000
const relay = Relay(r) const relay = await Relay.connect(r, { timeout })
await relay.publish(e, { timeout })
function timedout () {
relay.close()
console.log('failed to send to', r)
reject(new Error('relay timeout'))
}
let timer = setTimeout(timedout, timeout)
relay.on('open', () => {
clearTimeout(timer)
timer = setTimeout(timedout, timeout)
relay.send(['EVENT', e])
}) })
)
relay.on('ok', () => {
clearTimeout(timer)
relay.close()
console.log('sent zap to', r)
resolve()
})
})))
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }

View File

@ -239,9 +239,14 @@ async function subscribeToWithdrawals (args) {
} }
export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
// get the withdrawl if pending or it's an invoiceForward
const dbWdrwl = await models.withdrawl.findFirst({ const dbWdrwl = await models.withdrawl.findFirst({
where: { where: {
hash hash,
OR: [
{ status: null },
{ invoiceForward: { some: { } } }
]
}, },
include: { include: {
wallet: true, wallet: true,
@ -253,16 +258,9 @@ export async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
} }
} }
}) })
if (!dbWdrwl) {
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
// >>> an adversary might be draining our funds right now <<<
console.error('unexpected outgoing payment detected:', hash)
// TODO: log this in Slack
return
}
// already recorded and no invoiceForward to handle // nothing to do if the withdrawl is already recorded and it isn't an invoiceForward
if (dbWdrwl.status && dbWdrwl.invoiceForward.length === 0) return if (!dbWdrwl) return
let wdrwl let wdrwl
let notSent = false let notSent = false
@ -395,7 +393,9 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss,
// if this is an actionType we need to cancel conditionally // if this is an actionType we need to cancel conditionally
if (dbInv.actionType) { if (dbInv.actionType) {
return await paidActionCanceling({ data: { invoiceId: dbInv.id }, models, lnd, boss }) await paidActionCanceling({ data: { invoiceId: dbInv.id }, models, lnd, boss })
await checkInvoice({ data: { hash }, models, lnd, ...args })
return
} }
await cancelHodlInvoice({ id: hash, lnd }) await cancelHodlInvoice({ id: hash, lnd })