}
+ */
+ async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) {
+ const event = await this.sign({ kind, created_at, content, tags }, { signer })
- if (type !== 'OK' || eventId !== event.id) return
-
- if (accepted) {
- resolve(eventId)
- } else {
- reject(new Error(reason || `event rejected: ${eventId}`))
- }
- }
+ const successfulRelays = []
+ const failedRelays = []
- ws.addEventListener('message', listener)
+ const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)
- ws.send(JSON.stringify(['EVENT', event]))
+ event.on('relay:publish:failed', (relay, error) => {
+ failedRelays.push({ relay: relay.url, error })
})
- try {
- return await withTimeout(ackPromise, timeout)
- } finally {
- ws.removeEventListener('message', listener)
+ for (const relay of (await relaySet.publish(event, timeout))) {
+ successfulRelays.push(relay.url)
+ }
+
+ return {
+ event,
+ successfulRelays,
+ failedRelays
}
}
- async fetch (filter, { timeout } = {}) {
- const ws = this.ws
-
- let listener
- const ackPromise = new Promise((resolve, reject) => {
- const id = crypto.randomBytes(16).toString('hex')
-
- const events = []
- let eose = false
-
- listener = function onmessage (msg) {
- const [type, subId, event] = JSON.parse(msg.data)
-
- if (subId !== id) return
-
- if (type === 'EVENT') {
- events.push(event)
- if (eose) {
- // EOSE was already received:
- // return first event after EOSE
- resolve(events)
- }
- return
- }
-
- if (type === 'CLOSED') {
- return resolve(events)
- }
-
- if (type === 'EOSE') {
- eose = true
- if (events.length > 0) {
- // we already received events before EOSE:
- // return all events before EOSE
- ws.send(JSON.stringify(['CLOSE', id]))
- return resolve(events)
- }
- }
+ async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) {
+ try {
+ signer ??= this.getSigner({ supportNip07: true })
+ const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout })
+
+ let noteId = null
+ if (signedEvent.kind !== 1) {
+ noteId = await nip19.naddrEncode({
+ kind: signedEvent.kind,
+ pubkey: signedEvent.pubkey,
+ identifier: signedEvent.tags[0][1]
+ })
+ } else {
+ noteId = hexToBech32(signedEvent.id, 'note')
}
- ws.addEventListener('message', listener)
-
- ws.send(JSON.stringify(['REQ', id, ...filter]))
- })
-
- try {
- return await withTimeout(ackPromise, timeout)
- } finally {
- ws.removeEventListener('message', listener)
+ return { successfulRelays, failedRelays, noteId }
+ } catch (error) {
+ console.error('Crosspost error:', error)
+ return { error }
}
}
}
@@ -187,48 +174,10 @@ export function nostrZapDetails (zap) {
return { npub, content, note }
}
-async function publishNostrEvent (signedEvent, relayUrl) {
- const timeout = 3000
- const relay = await Relay.connect(relayUrl, { timeout })
- try {
- await relay.publish(signedEvent, { timeout })
- } finally {
- relay.close()
- }
-}
-
-export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
- try {
- const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 10000)
- if (!signedEvent) throw new Error('failed to sign event')
-
- const promises = relays.map(r => publishNostrEvent(signedEvent, r))
- const results = await Promise.allSettled(promises)
- const successfulRelays = []
- const failedRelays = []
-
- results.forEach((result, index) => {
- if (result.status === 'fulfilled') {
- successfulRelays.push(relays[index])
- } else {
- failedRelays.push({ relay: relays[index], error: result.reason })
- }
- })
-
- let noteId = null
- if (signedEvent.kind !== 1) {
- noteId = await nip19.naddrEncode({
- kind: signedEvent.kind,
- pubkey: signedEvent.pubkey,
- identifier: signedEvent.tags[0][1]
- })
- } else {
- noteId = hexToBech32(signedEvent.id, 'note')
- }
-
- return { successfulRelays, failedRelays, noteId }
- } catch (error) {
- console.error('Crosspost error:', error)
- return { error }
+// workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636)
+class NDKNip46SignerURLPatch extends NDKNip46Signer {
+ connectionTokenInit (connectionToken) {
+ connectionToken = connectionToken.replace('bunker://', 'http://')
+ return super.connectionTokenInit(connectionToken)
}
}
diff --git a/lib/time.js b/lib/time.js
index 22589c6b2..cce8f7566 100644
--- a/lib/time.js
+++ b/lib/time.js
@@ -128,12 +128,22 @@ function tzOffset (tz) {
return targetOffsetHours
}
+export class TimeoutError extends Error {
+ constructor (timeout) {
+ super(`timeout after ${timeout / 1000}s`)
+ this.name = 'TimeoutError'
+ this.timeout = timeout
+ }
+}
+
function timeoutPromise (timeout) {
return new Promise((resolve, reject) => {
// if no timeout is specified, never settle
if (!timeout) return
- setTimeout(() => reject(new Error(`timeout after ${timeout / 1000}s`)), timeout)
+ // delay timeout by 100ms so any parallel promise with same timeout will throw first
+ const delay = 100
+ setTimeout(() => reject(new TimeoutError(timeout)), timeout + delay)
})
}
@@ -144,3 +154,16 @@ export async function withTimeout (promise, timeout) {
export async function callWithTimeout (fn, timeout) {
return await Promise.race([fn(), timeoutPromise(timeout)])
}
+
+// AbortSignal.timeout with our custom timeout error message
+export function timeoutSignal (timeout) {
+ const controller = new AbortController()
+
+ if (timeout) {
+ setTimeout(() => {
+ controller.abort(new TimeoutError(timeout))
+ }, timeout)
+ }
+
+ return controller.signal
+}
diff --git a/lib/url.js b/lib/url.js
index d6eca9032..3bb51e3f2 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -203,12 +203,12 @@ export function parseNwcUrl (walletConnectUrl) {
const params = {}
params.walletPubkey = url.host
const secret = url.searchParams.get('secret')
- const relayUrl = url.searchParams.get('relay')
+ const relayUrls = url.searchParams.getAll('relay')
if (secret) {
params.secret = secret
}
- if (relayUrl) {
- params.relayUrl = relayUrl
+ if (relayUrls) {
+ params.relayUrls = relayUrls
}
return params
}
diff --git a/lib/yup.js b/lib/yup.js
index fbbf0b12e..745cb5df6 100644
--- a/lib/yup.js
+++ b/lib/yup.js
@@ -147,15 +147,15 @@ addMethod(string, 'nwcUrl', function () {
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
try {
string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl)
- let relayUrl, walletPubkey, secret
+ let relayUrls, walletPubkey, secret
try {
- ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
+ ({ relayUrls, walletPubkey, secret } = parseNwcUrl(nwcUrl))
} catch {
// invalid URL error. handle as if pubkey validation failed to not confuse user.
throw new Error('pubkey must be 64 hex chars')
}
string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey)
- string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl)
+ array().of(string().required('relay url required').trim().wss('relay must use wss://')).min(1, 'at least one relay required').validateSync(relayUrls)
string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret)
} catch (err) {
return context.createError({ message: err.message })
diff --git a/package-lock.json b/package-lock.json
index 47a763f06..6bb7b2873 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@graphql-tools/schema": "^10.0.6",
"@lightninglabs/lnc-web": "^0.3.2-alpha",
"@noble/curves": "^1.6.0",
+ "@nostr-dev-kit/ndk": "^2.10.5",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@slack/web-api": "^7.6.0",
@@ -4372,6 +4373,15 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/secp256k1": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.1.0.tgz",
+ "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4407,6 +4417,49 @@
"node": ">= 8"
}
},
+ "node_modules/@nostr-dev-kit/ndk": {
+ "version": "2.10.5",
+ "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.5.tgz",
+ "integrity": "sha512-QEnarJL9BGCxeenSIE9jxNSDyYQYjzD30YL3sVJ9cNybNZX8tl/I1/vhEUeRRMBz/qjROLtt0M2RV68rZ205tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^1.6.0",
+ "@noble/hashes": "^1.5.0",
+ "@noble/secp256k1": "^2.1.0",
+ "@scure/base": "^1.1.9",
+ "debug": "^4.3.6",
+ "light-bolt11-decoder": "^3.2.0",
+ "nostr-tools": "^2.7.1",
+ "tseep": "^1.2.2",
+ "typescript-lru-cache": "^2.0.0",
+ "utf8-buffer": "^1.0.0",
+ "websocket-polyfill": "^0.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz",
+ "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@nostr-dev-kit/ndk/node_modules/@scure/base": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
+ "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@opensearch-project/opensearch": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-2.12.0.tgz",
@@ -7311,6 +7364,19 @@
"node": ">=4"
}
},
+ "node_modules/bufferutil": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
+ "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=6.14.2"
+ }
+ },
"node_modules/builtins": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
@@ -8090,6 +8156,19 @@
"node": ">= 10"
}
},
+ "node_modules/d": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+ "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+ "license": "ISC",
+ "dependencies": {
+ "es5-ext": "^0.10.64",
+ "type": "^2.7.2"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -8969,6 +9048,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es5-ext": {
+ "version": "0.10.64",
+ "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+ "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+ "hasInstallScript": true,
+ "license": "ISC",
+ "dependencies": {
+ "es6-iterator": "^2.0.3",
+ "es6-symbol": "^3.1.3",
+ "esniff": "^2.0.1",
+ "next-tick": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/es6-iterator": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+ "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+ "license": "MIT",
+ "dependencies": {
+ "d": "1",
+ "es5-ext": "^0.10.35",
+ "es6-symbol": "^3.1.1"
+ }
+ },
+ "node_modules/es6-symbol": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+ "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+ "license": "ISC",
+ "dependencies": {
+ "d": "^1.0.2",
+ "ext": "^1.7.0"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
"node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
@@ -9582,6 +9701,21 @@
"node": ">=6"
}
},
+ "node_modules/esniff": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+ "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+ "license": "ISC",
+ "dependencies": {
+ "d": "^1.0.1",
+ "es5-ext": "^0.10.62",
+ "event-emitter": "^0.3.5",
+ "type": "^2.7.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/espree": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz",
@@ -9676,6 +9810,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/event-emitter": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+ "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+ "license": "MIT",
+ "dependencies": {
+ "d": "1",
+ "es5-ext": "~0.10.14"
+ }
+ },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -9830,6 +9974,15 @@
}
]
},
+ "node_modules/ext": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+ "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+ "license": "ISC",
+ "dependencies": {
+ "type": "^2.7.2"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -14155,6 +14308,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/light-bolt11-decoder": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz",
+ "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@scure/base": "1.1.1"
+ }
+ },
"node_modules/lightning": {
"version": "10.22.0",
"resolved": "https://registry.npmjs.org/lightning/-/lightning-10.22.0.tgz",
@@ -15607,6 +15769,12 @@
"react-dom": ">=16.0.0"
}
},
+ "node_modules/next-tick": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
+ "license": "ISC"
+ },
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -19433,11 +19601,23 @@
"resolved": "https://registry.npmjs.org/tsdef/-/tsdef-0.0.14.tgz",
"integrity": "sha512-UjMD4XKRWWFlFBfwKVQmGFT5YzW/ZaF8x6KpCDf92u9wgKeha/go3FU0e5WqDjXsCOdfiavCkfwfVHNDxRDGMA=="
},
+ "node_modules/tseep": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz",
+ "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
+ "node_modules/tstl": {
+ "version": "2.5.16",
+ "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz",
+ "integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==",
+ "license": "MIT"
+ },
"node_modules/tsx": {
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz",
@@ -19477,6 +19657,12 @@
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="
},
+ "node_modules/type": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
+ "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
+ "license": "ISC"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -19599,11 +19785,26 @@
"node": ">= 18"
}
},
+ "node_modules/typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "license": "MIT",
+ "dependencies": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
"node_modules/typeforce": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
+ "node_modules/typescript-lru-cache": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz",
+ "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==",
+ "license": "MIT"
+ },
"node_modules/uint8array-tools": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz",
@@ -20022,6 +20223,28 @@
}
}
},
+ "node_modules/utf-8-validate": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
+ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=6.14.2"
+ }
+ },
+ "node_modules/utf8-buffer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/utf8-buffer/-/utf8-buffer-1.0.0.tgz",
+ "integrity": "sha512-ueuhzvWnp5JU5CiGSY4WdKbiN/PO2AZ/lpeLiz2l38qwdLy/cW40XobgyuIWucNyum0B33bVB0owjFCeGBSLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/util": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz",
@@ -20345,6 +20568,47 @@
"npm": ">=3.10.0"
}
},
+ "node_modules/websocket": {
+ "version": "1.0.35",
+ "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
+ "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bufferutil": "^4.0.1",
+ "debug": "^2.2.0",
+ "es5-ext": "^0.10.63",
+ "typedarray-to-buffer": "^3.1.5",
+ "utf-8-validate": "^5.0.2",
+ "yaeti": "^0.0.6"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/websocket-polyfill": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz",
+ "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==",
+ "dependencies": {
+ "tstl": "^2.0.7",
+ "websocket": "^1.0.28"
+ }
+ },
+ "node_modules/websocket/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/websocket/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -20921,6 +21185,15 @@
"node": ">=10"
}
},
+ "node_modules/yaeti": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
+ "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.32"
+ }
+ },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
diff --git a/package.json b/package.json
index 1b9189c3a..241365f70 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@graphql-tools/schema": "^10.0.6",
"@lightninglabs/lnc-web": "^0.3.2-alpha",
"@noble/curves": "^1.6.0",
+ "@nostr-dev-kit/ndk": "^2.10.5",
"@opensearch-project/opensearch": "^2.12.0",
"@prisma/client": "^5.20.0",
"@slack/web-api": "^7.6.0",
diff --git a/pages/rewards/index.js b/pages/rewards/index.js
index 164710f4c..a81d88437 100644
--- a/pages/rewards/index.js
+++ b/pages/rewards/index.js
@@ -16,7 +16,6 @@ import { useToast } from '@/components/toast'
import { useLightning } from '@/components/lightning'
import { ListUsers } from '@/components/user-list'
import { Col, Row } from 'react-bootstrap'
-import { proportions } from '@/lib/madness'
import { useData } from '@/components/use-data'
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
import { useMemo } from 'react'
@@ -50,6 +49,7 @@ ${ITEM_FULL_FIELDS}
photoId
ncomments
nposts
+ proportion
optional {
streak
@@ -117,9 +117,10 @@ export default function Rewards ({ ssrData }) {
if (!dat) return
- function EstimatedReward ({ rank }) {
- const referrerReward = Math.floor(total * proportions[rank - 1] * 0.2)
- const reward = Math.floor(total * proportions[rank - 1]) - referrerReward
+ function EstimatedReward ({ rank, user }) {
+ if (!user) return null
+ const referrerReward = Math.max(Math.floor(total * user.proportion * 0.2), 0)
+ const reward = Math.max(Math.floor(total * user.proportion) - referrerReward, 0)
return (
diff --git a/prisma/migrations/20241206155927_invoice_predecessors/migration.sql b/prisma/migrations/20241206155927_invoice_predecessors/migration.sql
new file mode 100644
index 000000000..20260a451
--- /dev/null
+++ b/prisma/migrations/20241206155927_invoice_predecessors/migration.sql
@@ -0,0 +1,14 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[predecessorId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- AlterTable
+ALTER TABLE "Invoice" ADD COLUMN "predecessorId" INTEGER;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Invoice.predecessorId_unique" ON "Invoice"("predecessorId");
+
+-- AddForeignKey
+ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20241217163642_user_values_improve/migration.sql b/prisma/migrations/20241217163642_user_values_improve/migration.sql
new file mode 100644
index 000000000..47f21be36
--- /dev/null
+++ b/prisma/migrations/20241217163642_user_values_improve/migration.sql
@@ -0,0 +1,93 @@
+CREATE OR REPLACE FUNCTION user_values(
+ min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
+ percentile_cutoff INTEGER DEFAULT 50,
+ each_upvote_portion FLOAT DEFAULT 4.0,
+ each_item_portion FLOAT DEFAULT 4.0,
+ handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}',
+ handicap_zap_mult FLOAT DEFAULT 0.3)
+RETURNS TABLE (
+ t TIMESTAMP(3), id INTEGER, proportion FLOAT
+)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
+BEGIN
+ RETURN QUERY
+ SELECT period.t, u."userId", u.total_proportion
+ FROM generate_series(min, max, ival) period(t),
+ LATERAL
+ (WITH item_ratios AS (
+ SELECT *,
+ CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
+ CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
+ FROM (
+ SELECT *,
+ NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
+ ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
+ FROM
+ "Item"
+ WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
+ AND "weightedVotes" > 0
+ AND "deletedAt" IS NULL
+ AND NOT bio
+ AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
+ ) x
+ WHERE x.percentile <= percentile_cutoff
+ ),
+ -- get top upvoters of top posts and comments
+ upvoter_islands AS (
+ SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
+ "ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
+ ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
+ - ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
+ FROM item_ratios
+ JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
+ WHERE act = 'TIP'
+ AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
+ AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
+ ),
+ -- isolate contiguous upzaps from the same user on the same item so that when we take the log
+ -- of the upzaps it accounts for successive zaps and does not disproportionately reward them
+ -- quad root of the total tipped
+ upvoters AS (
+ SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at
+ FROM upvoter_islands
+ GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
+ ),
+ -- the relative contribution of each upvoter to the post/comment
+ -- early component: 1/ln(early_rank + e - 1)
+ -- tipped component: how much they tipped relative to the total tipped for the item
+ -- multiplied by the relative rank of the item to the total items
+ -- multiplied by the trust of the user
+ upvoter_ratios AS (
+ SELECT "userId", sum((early_multiplier+tipped_ratio)*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE users.trust+0.1 END) as upvoter_ratio,
+ "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
+ FROM (
+ SELECT *,
+ 1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier,
+ tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
+ FROM upvoters
+ WHERE tipped > 2.1
+ ) u
+ JOIN users on "userId" = users.id
+ GROUP BY "userId", "parentId" IS NULL
+ ),
+ proportions AS (
+ SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
+ upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
+ FROM upvoter_ratios
+ WHERE upvoter_ratio > 0
+ UNION ALL
+ SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
+ FROM item_ratios
+ )
+ SELECT "userId", sum(proportions.proportion) AS total_proportion
+ FROM proportions
+ GROUP BY "userId"
+ HAVING sum(proportions.proportion) > 0.000001) u;
+END;
+$$;
+
+REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today;
+REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days;
\ No newline at end of file
diff --git a/prisma/migrations/20241219002217_improve_values_again/migration.sql b/prisma/migrations/20241219002217_improve_values_again/migration.sql
new file mode 100644
index 000000000..2e8bfa37c
--- /dev/null
+++ b/prisma/migrations/20241219002217_improve_values_again/migration.sql
@@ -0,0 +1,94 @@
+CREATE OR REPLACE FUNCTION user_values(
+ min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
+ percentile_cutoff INTEGER DEFAULT 50,
+ each_upvote_portion FLOAT DEFAULT 4.0,
+ each_item_portion FLOAT DEFAULT 4.0,
+ handicap_ids INTEGER[] DEFAULT '{616, 6030, 4502, 27}',
+ handicap_zap_mult FLOAT DEFAULT 0.3)
+RETURNS TABLE (
+ t TIMESTAMP(3), id INTEGER, proportion FLOAT
+)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
+BEGIN
+ RETURN QUERY
+ SELECT period.t, u."userId", u.total_proportion
+ FROM generate_series(min, max, ival) period(t),
+ LATERAL
+ (WITH item_ratios AS (
+ SELECT *,
+ CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
+ CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
+ FROM (
+ SELECT *,
+ NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
+ ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
+ FROM
+ "Item"
+ WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
+ AND "weightedVotes" > 0
+ AND "deletedAt" IS NULL
+ AND NOT bio
+ AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
+ ) x
+ WHERE x.percentile <= percentile_cutoff
+ ),
+ -- get top upvoters of top posts and comments
+ upvoter_islands AS (
+ SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
+ "ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
+ ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
+ - ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
+ FROM item_ratios
+ JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
+ WHERE act = 'TIP'
+ AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
+ AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
+ ),
+ -- isolate contiguous upzaps from the same user on the same item so that when we take the log
+ -- of the upzaps it accounts for successive zaps and does not disproportionately reward them
+ -- quad root of the total tipped
+ upvoters AS (
+ SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(power(sum(tipped) / 1000, 0.25), 0) as tipped, min(acted_at) as acted_at
+ FROM upvoter_islands
+ GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
+ HAVING CASE WHEN "parentId" IS NULL THEN sum(tipped) / 1000 > 40 ELSE sum(tipped) / 1000 > 20 END
+ ),
+ -- the relative contribution of each upvoter to the post/comment
+ -- early component: 1/ln(early_rank + e - 1)
+ -- tipped component: how much they tipped relative to the total tipped for the item
+ -- multiplied by the relative rank of the item to the total items
+ -- multiplied by the trust of the user
+ upvoter_ratios AS (
+ SELECT "userId", sum((2*early_multiplier+1)*tipped_ratio*ratio) as upvoter_ratio,
+ "parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
+ FROM (
+ SELECT *,
+ 1.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0) - 1) AS early_multiplier,
+ tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
+ FROM upvoters
+ WHERE tipped > 0
+ ) u
+ JOIN users on "userId" = users.id
+ GROUP BY "userId", "parentId" IS NULL
+ ),
+ proportions AS (
+ SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
+ upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
+ FROM upvoter_ratios
+ WHERE upvoter_ratio > 0
+ UNION ALL
+ SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
+ FROM item_ratios
+ )
+ SELECT "userId", sum(proportions.proportion) AS total_proportion
+ FROM proportions
+ GROUP BY "userId"
+ HAVING sum(proportions.proportion) > 0.000001) u;
+END;
+$$;
+
+REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today;
+REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days;
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2aac87a0c..390498324 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -905,39 +905,41 @@ model ItemMention {
}
model Invoice {
- id Int @id @default(autoincrement())
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
- userId Int
- hash String @unique(map: "Invoice.hash_unique")
- preimage String? @unique(map: "Invoice.preimage_unique")
- isHeld Boolean?
- bolt11 String
- expiresAt DateTime
- confirmedAt DateTime?
- confirmedIndex BigInt?
- cancelled Boolean @default(false)
- cancelledAt DateTime?
- msatsRequested BigInt
- msatsReceived BigInt?
- desc String?
- comment String?
- lud18Data Json?
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- invoiceForward InvoiceForward?
-
- actionState InvoiceActionState?
- actionType InvoiceActionType?
- actionOptimistic Boolean?
- actionId Int?
- actionArgs Json? @db.JsonB
- actionError String?
- actionResult Json? @db.JsonB
- ItemAct ItemAct[]
- Item Item[]
- Upload Upload[]
- PollVote PollVote[]
- PollBlindVote PollBlindVote[]
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
+ userId Int
+ hash String @unique(map: "Invoice.hash_unique")
+ preimage String? @unique(map: "Invoice.preimage_unique")
+ isHeld Boolean?
+ bolt11 String
+ expiresAt DateTime
+ confirmedAt DateTime?
+ confirmedIndex BigInt?
+ cancelled Boolean @default(false)
+ cancelledAt DateTime?
+ msatsRequested BigInt
+ msatsReceived BigInt?
+ desc String?
+ comment String?
+ lud18Data Json?
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ invoiceForward InvoiceForward?
+ predecessorId Int? @unique(map: "Invoice.predecessorId_unique")
+ predecessorInvoice Invoice? @relation("PredecessorInvoice", fields: [predecessorId], references: [id], onDelete: Cascade)
+ successorInvoice Invoice? @relation("PredecessorInvoice")
+ actionState InvoiceActionState?
+ actionType InvoiceActionType?
+ actionOptimistic Boolean?
+ actionId Int?
+ actionArgs Json? @db.JsonB
+ actionError String?
+ actionResult Json? @db.JsonB
+ ItemAct ItemAct[]
+ Item Item[]
+ Upload Upload[]
+ PollVote PollVote[]
+ PollBlindVote PollBlindVote[]
@@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index")
diff --git a/scripts/test-routing.sh b/scripts/test-routing.sh
new file mode 100644
index 000000000..7ce7b75b6
--- /dev/null
+++ b/scripts/test-routing.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+# test if every node can pay invoices from every other node
+
+SN_LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
+LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
+CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
+
+# -e: exit on first failure | -x: print commands
+set -ex
+
+sndev cli lnd queryroutes $SN_LND_PUBKEY 1000
+sndev cli lnd queryroutes $CLN_PUBKEY 1000
+
+sndev cli sn_lnd queryroutes $LND_PUBKEY 1000
+sndev cli sn_lnd queryroutes $CLN_PUBKEY 1000
+
+# https://docs.corelightning.org/reference/lightning-getroute
+sndev cli cln getroute $LND_PUBKEY 1000 0
+sndev cli cln getroute $SN_LND_PUBKEY 1000 0
diff --git a/wallets/blink/client.js b/wallets/blink/client.js
index 8779d64ae..c5a487b83 100644
--- a/wallets/blink/client.js
+++ b/wallets/blink/client.js
@@ -1,9 +1,10 @@
import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common'
export * from '@/wallets/blink'
-export async function testSendPayment ({ apiKey, currency }, { logger }) {
+export async function testSendPayment ({ apiKey, currency }, { logger, signal }) {
logger.info('trying to fetch ' + currency + ' wallet')
- const scopes = await getScopes(apiKey)
+
+ const scopes = await getScopes({ apiKey }, { signal })
if (!scopes.includes(SCOPE_READ)) {
throw new Error('missing READ scope')
}
@@ -12,46 +13,48 @@ export async function testSendPayment ({ apiKey, currency }, { logger }) {
}
currency = currency ? currency.toUpperCase() : 'BTC'
- await getWallet(apiKey, currency)
+ await getWallet({ apiKey, currency }, { signal })
logger.ok(currency + ' wallet found')
}
-export async function sendPayment (bolt11, { apiKey, currency }) {
- const wallet = await getWallet(apiKey, currency)
- return await payInvoice(apiKey, wallet, bolt11)
+export async function sendPayment (bolt11, { apiKey, currency }, { signal }) {
+ const wallet = await getWallet({ apiKey, currency }, { signal })
+ return await payInvoice(bolt11, { apiKey, wallet }, { signal })
}
-async function payInvoice (authToken, wallet, invoice) {
- const walletId = wallet.id
- const out = await request(authToken, `
- mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
+async function payInvoice (bolt11, { apiKey, wallet }, { signal }) {
+ const out = await request({
+ apiKey,
+ query: `
+ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
lnInvoicePaymentSend(input: $input) {
- status
- errors {
- message
- path
- code
- }
- transaction {
- settlementVia {
- ... on SettlementViaIntraLedger {
- preImage
- }
- ... on SettlementViaLn {
- preImage
- }
- }
+ status
+ errors {
+ message
+ path
+ code
+ }
+ transaction {
+ settlementVia {
+ ... on SettlementViaIntraLedger {
+ preImage
+ }
+ ... on SettlementViaLn {
+ preImage
+ }
}
+ }
}
+ }`,
+ variables: {
+ input: {
+ paymentRequest: bolt11,
+ walletId: wallet.id
+ }
}
- `,
- {
- input: {
- paymentRequest: invoice,
- walletId
- }
- })
+ }, { signal })
+
const status = out.data.lnInvoicePaymentSend.status
const errors = out.data.lnInvoicePaymentSend.errors
if (errors && errors.length > 0) {
@@ -76,7 +79,7 @@ async function payInvoice (authToken, wallet, invoice) {
// at some point it should either be settled or fail on the backend, so the loop will exit
await new Promise(resolve => setTimeout(resolve, 100))
- const txInfo = await getTxInfo(authToken, wallet, invoice)
+ const txInfo = await getTxInfo(bolt11, { apiKey, wallet }, { signal })
// settled
if (txInfo.status === 'SUCCESS') {
if (!txInfo.preImage) throw new Error('no preimage')
@@ -95,36 +98,37 @@ async function payInvoice (authToken, wallet, invoice) {
throw new Error('unexpected error')
}
-async function getTxInfo (authToken, wallet, invoice) {
- const walletId = wallet.id
+async function getTxInfo (bolt11, { apiKey, wallet }, { signal }) {
let out
try {
- out = await request(authToken, `
- query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
- me {
- defaultAccount {
- walletById(walletId: $walletId) {
- transactionsByPaymentRequest(paymentRequest: $paymentRequest) {
- status
- direction
- settlementVia {
+ out = await request({
+ apiKey,
+ query: `
+ query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
+ me {
+ defaultAccount {
+ walletById(walletId: $walletId) {
+ transactionsByPaymentRequest(paymentRequest: $paymentRequest) {
+ status
+ direction
+ settlementVia {
... on SettlementViaIntraLedger {
- preImage
+ preImage
}
... on SettlementViaLn {
- preImage
+ preImage
}
+ }
}
}
}
}
- }
+ }`,
+ variables: {
+ paymentRequest: bolt11,
+ walletId: wallet.Id
}
- `,
- {
- paymentRequest: invoice,
- walletId
- })
+ }, { signal })
} catch (e) {
// something went wrong during the query,
// maybe the connection was lost, so we just return
diff --git a/wallets/blink/common.js b/wallets/blink/common.js
index bf03f0786..d0e46c3d3 100644
--- a/wallets/blink/common.js
+++ b/wallets/blink/common.js
@@ -1,3 +1,4 @@
+import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
@@ -7,38 +8,42 @@ export const SCOPE_READ = 'READ'
export const SCOPE_WRITE = 'WRITE'
export const SCOPE_RECEIVE = 'RECEIVE'
-export async function getWallet (authToken, currency) {
- const out = await request(authToken, `
+export async function getWallet ({ apiKey, currency }, { signal }) {
+ const out = await request({
+ apiKey,
+ query: `
query me {
- me {
- defaultAccount {
- wallets {
- id
- walletCurrency
- }
- }
+ me {
+ defaultAccount {
+ wallets {
+ id
+ walletCurrency
+ }
}
- }
- `, {})
+ }
+ }`
+ }, { signal })
+
const wallets = out.data.me.defaultAccount.wallets
for (const wallet of wallets) {
if (wallet.walletCurrency === currency) {
return wallet
}
}
+
throw new Error(`wallet ${currency} not found`)
}
-export async function request (authToken, query, variables = {}) {
- const options = {
+export async function request ({ apiKey, query, variables = {} }, { signal }) {
+ const res = await fetchWithTimeout(galoyBlinkUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-API-KEY': authToken
+ 'X-API-KEY': apiKey
},
- body: JSON.stringify({ query, variables })
- }
- const res = await fetch(galoyBlinkUrl, options)
+ body: JSON.stringify({ query, variables }),
+ signal
+ })
assertResponseOk(res)
assertContentTypeJson(res)
@@ -46,14 +51,16 @@ export async function request (authToken, query, variables = {}) {
return res.json()
}
-export async function getScopes (authToken) {
- const out = await request(authToken, `
- query scopes {
+export async function getScopes ({ apiKey }, { signal }) {
+ const out = await request({
+ apiKey,
+ query: `
+ query scopes {
authorization {
- scopes
+ scopes
}
- }
- `, {})
+ }`
+ }, { signal })
const scopes = out?.data?.authorization?.scopes
return scopes || []
}
diff --git a/wallets/blink/server.js b/wallets/blink/server.js
index 937a7ebad..0d1f2748f 100644
--- a/wallets/blink/server.js
+++ b/wallets/blink/server.js
@@ -1,10 +1,9 @@
-import { withTimeout } from '@/lib/time'
import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common'
import { msatsToSats } from '@/lib/format'
export * from '@/wallets/blink'
-export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) {
- const scopes = await getScopes(apiKeyRecv)
+export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) {
+ const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal })
if (!scopes.includes(SCOPE_READ)) {
throw new Error('missing READ scope')
}
@@ -15,47 +14,50 @@ export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) {
throw new Error('missing RECEIVE scope')
}
- const timeout = 15_000
currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC'
- return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }), timeout)
+ return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal })
}
export async function createInvoice (
{ msats, description, expiry },
- { apiKeyRecv, currencyRecv }) {
- currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC'
+ { apiKeyRecv: apiKey, currencyRecv: currency },
+ { signal }) {
+ currency = currency ? currency.toUpperCase() : 'BTC'
- const wallet = await getWallet(apiKeyRecv, currencyRecv)
+ const wallet = await getWallet({ apiKey, currency }, { signal })
- if (currencyRecv !== 'BTC') {
- throw new Error('unsupported currency ' + currencyRecv)
+ if (currency !== 'BTC') {
+ throw new Error('unsupported currency ' + currency)
}
- const mutation = `
- mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
- lnInvoiceCreate(input: $input) {
- invoice {
- paymentRequest
- }
- errors {
- message
- }
- }
+
+ const out = await request({
+ apiKey,
+ query: `
+ mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) {
+ lnInvoiceCreate(input: $input) {
+ invoice {
+ paymentRequest
+ }
+ errors {
+ message
+ }
}
- `
-
- const out = await request(apiKeyRecv, mutation, {
- input: {
- amount: msatsToSats(msats),
- expiresIn: Math.floor(expiry / 60) || 1,
- memo: description,
- walletId: wallet.id
+ }`,
+ variables: {
+ input: {
+ amount: msatsToSats(msats),
+ expiresIn: Math.floor(expiry / 60) || 1,
+ memo: description,
+ walletId: wallet.id
+ }
}
- })
+ }, { signal })
+
const res = out.data.lnInvoiceCreate
const errors = res.errors
if (errors && errors.length > 0) {
throw new Error(errors.map(e => e.code + ' ' + e.message).join(', '))
}
- const invoice = res.invoice.paymentRequest
- return invoice
+
+ return res.invoice.paymentRequest
}
diff --git a/wallets/cln/server.js b/wallets/cln/server.js
index 53b958b18..2916e944b 100644
--- a/wallets/cln/server.js
+++ b/wallets/cln/server.js
@@ -2,23 +2,26 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln'
export * from '@/wallets/cln'
-export const testCreateInvoice = async ({ socket, rune, cert }) => {
- return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert })
+export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => {
+ return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal })
}
export const createInvoice = async (
- { msats, description, descriptionHash, expiry },
- { socket, rune, cert }
-) => {
- const inv = await clnCreateInvoice({
- socket,
- rune,
- cert,
- description,
- descriptionHash,
- msats,
- expiry
- })
+ { msats, description, expiry },
+ { socket, rune, cert },
+ { signal }) => {
+ const inv = await clnCreateInvoice(
+ {
+ msats,
+ description,
+ expiry
+ },
+ {
+ socket,
+ rune,
+ cert
+ },
+ { signal })
return inv.bolt11
}
diff --git a/wallets/config.js b/wallets/config.js
index 8ddcfc71f..cdc574240 100644
--- a/wallets/config.js
+++ b/wallets/config.js
@@ -8,6 +8,8 @@ import { REMOVE_WALLET } from '@/fragments/wallet'
import { useWalletLogger } from '@/wallets/logger'
import { useWallets } from '.'
import validateWallet from './validate'
+import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
+import { timeoutSignal, withTimeout } from '@/lib/time'
export function useWalletConfigurator (wallet) {
const { me } = useMe()
@@ -37,17 +39,28 @@ export function useWalletConfigurator (wallet) {
let serverConfig = serverWithShared
if (canSend({ def: wallet.def, config: clientConfig })) {
- let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true })
- if (transformedConfig) {
- clientConfig = Object.assign(clientConfig, transformedConfig)
- }
- if (wallet.def.testSendPayment && validateLightning) {
- transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger })
+ try {
+ let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true })
if (transformedConfig) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
- // validate again to ensure generated fields are valid
- await validateWallet(wallet.def, clientConfig)
+ if (wallet.def.testSendPayment && validateLightning) {
+ transformedConfig = await withTimeout(
+ wallet.def.testSendPayment(clientConfig, {
+ logger,
+ signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
+ }),
+ WALLET_SEND_PAYMENT_TIMEOUT_MS
+ )
+ if (transformedConfig) {
+ clientConfig = Object.assign(clientConfig, transformedConfig)
+ }
+ // validate again to ensure generated fields are valid
+ await validateWallet(wallet.def, clientConfig)
+ }
+ } catch (err) {
+ logger.error(err.message)
+ throw err
}
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
const transformedConfig = await validateWallet(wallet.def, serverConfig)
@@ -71,33 +84,52 @@ export function useWalletConfigurator (wallet) {
}, [me?.id, wallet.def.name, reloadLocalWallets])
const save = useCallback(async (newConfig, validateLightning = true) => {
- const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning)
+ const { clientWithShared: oldClientConfig } = siftConfig(wallet.def.fields, wallet.config)
+ const { clientConfig: newClientConfig, serverConfig: newServerConfig } = await _validate(newConfig, validateLightning)
+
+ const oldCanSend = canSend({ def: wallet.def, config: oldClientConfig })
+ const newCanSend = canSend({ def: wallet.def, config: newClientConfig })
// if vault is active, encrypt and send to server regardless of wallet type
if (isActive) {
- await _saveToServer(serverConfig, clientConfig, validateLightning)
+ await _saveToServer(newServerConfig, newClientConfig, validateLightning)
await _detachFromLocal()
} else {
- if (canSend({ def: wallet.def, config: clientConfig })) {
- await _saveToLocal(clientConfig)
+ if (newCanSend) {
+ await _saveToLocal(newClientConfig)
} else {
// if it previously had a client config, remove it
await _detachFromLocal()
}
- if (canReceive({ def: wallet.def, config: serverConfig })) {
- await _saveToServer(serverConfig, clientConfig, validateLightning)
+ if (canReceive({ def: wallet.def, config: newServerConfig })) {
+ await _saveToServer(newServerConfig, newClientConfig, validateLightning)
} else if (wallet.config.id) {
// we previously had a server config
if (wallet.vaultEntries.length > 0) {
// we previously had a server config with vault entries, save it
- await _saveToServer(serverConfig, clientConfig, validateLightning)
+ await _saveToServer(newServerConfig, newClientConfig, validateLightning)
} else {
// we previously had a server config without vault entries, remove it
await _detachFromServer()
}
}
}
- }, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate,
+
+ if (newCanSend) {
+ if (oldCanSend) {
+ logger.ok('details for sending updated')
+ } else {
+ logger.ok('details for sending saved')
+ }
+ if (newConfig.enabled) {
+ logger.ok('sending enabled')
+ } else {
+ logger.info('sending disabled')
+ }
+ } else if (oldCanSend) {
+ logger.info('details for sending deleted')
+ }
+ }, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate,
_detachFromLocal, _detachFromServer])
const detach = useCallback(async () => {
@@ -112,7 +144,9 @@ export function useWalletConfigurator (wallet) {
// if vault is not active and has a client config, delete from local storage
await _detachFromLocal()
}
- }, [isActive, _detachFromServer, _detachFromLocal])
+
+ logger.info('details for sending deleted')
+ }, [logger, isActive, _detachFromServer, _detachFromLocal])
return { save, detach }
}
diff --git a/wallets/errors.js b/wallets/errors.js
index 510d8e78b..13c9ca18c 100644
--- a/wallets/errors.js
+++ b/wallets/errors.js
@@ -47,6 +47,14 @@ export class WalletSenderError extends WalletPaymentError {
}
}
+export class WalletReceiverError extends WalletPaymentError {
+ constructor (invoice) {
+ super(`payment forwarding failed for invoice ${invoice.hash}`)
+ this.name = 'WalletReceiverError'
+ this.invoice = invoice
+ }
+}
+
export class WalletsNotAvailableError extends WalletConfigurationError {
constructor () {
super('no wallet available')
diff --git a/wallets/index.js b/wallets/index.js
index dbf6f45b3..bdf00083b 100644
--- a/wallets/index.js
+++ b/wallets/index.js
@@ -220,7 +220,7 @@ export function useWallet (name) {
export function useSendWallets () {
const { wallets } = useWallets()
- // return the first enabled wallet that is available and can send
+ // return all enabled wallets that are available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))
diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js
index f7e51356a..cb9edf84d 100644
--- a/wallets/lightning-address/server.js
+++ b/wallets/lightning-address/server.js
@@ -1,18 +1,20 @@
+import { fetchWithTimeout } from '@/lib/fetch'
import { msatsSatsFloor } from '@/lib/format'
import { lnAddrOptions } from '@/lib/lnurl'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/lightning-address'
-export const testCreateInvoice = async ({ address }) => {
- return await createInvoice({ msats: 1000 }, { address })
+export const testCreateInvoice = async ({ address }, { signal }) => {
+ return await createInvoice({ msats: 1000 }, { address }, { signal })
}
export const createInvoice = async (
{ msats, description },
- { address }
+ { address },
+ { signal }
) => {
- const { callback, commentAllowed } = await lnAddrOptions(address)
+ const { callback, commentAllowed } = await lnAddrOptions(address, { signal })
const callbackUrl = new URL(callback)
// most lnurl providers suck nards so we have to floor to nearest sat
@@ -25,7 +27,7 @@ export const createInvoice = async (
}
// call callback with amount and conditionally comment
- const res = await fetch(callbackUrl.toString())
+ const res = await fetchWithTimeout(callbackUrl.toString(), { signal })
assertResponseOk(res)
assertContentTypeJson(res)
diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js
index 61abe48d9..a850a0e63 100644
--- a/wallets/lnbits/client.js
+++ b/wallets/lnbits/client.js
@@ -1,22 +1,23 @@
+import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson } from '@/lib/url'
export * from '@/wallets/lnbits'
-export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) {
+export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) {
logger.info('trying to fetch wallet')
url = url.replace(/\/+$/, '')
- await getWallet({ url, adminKey, invoiceKey })
+ await getWallet({ url, adminKey, invoiceKey }, { signal })
logger.ok('wallet found')
}
-export async function sendPayment (bolt11, { url, adminKey }) {
+export async function sendPayment (bolt11, { url, adminKey }, { signal }) {
url = url.replace(/\/+$/, '')
- const response = await postPayment(bolt11, { url, adminKey })
+ const response = await postPayment(bolt11, { url, adminKey }, { signal })
- const checkResponse = await getPayment(response.payment_hash, { url, adminKey })
+ const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal })
if (!checkResponse.preimage) {
throw new Error('No preimage')
}
@@ -24,7 +25,7 @@ export async function sendPayment (bolt11, { url, adminKey }) {
return checkResponse.preimage
}
-async function getWallet ({ url, adminKey, invoiceKey }) {
+async function getWallet ({ url, adminKey, invoiceKey }, { signal }) {
const path = '/api/v1/wallet'
const headers = new Headers()
@@ -32,7 +33,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) {
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey || invoiceKey)
- const res = await fetch(url + path, { method: 'GET', headers })
+ const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal })
assertContentTypeJson(res)
if (!res.ok) {
@@ -44,7 +45,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) {
return wallet
}
-async function postPayment (bolt11, { url, adminKey }) {
+async function postPayment (bolt11, { url, adminKey }, { signal }) {
const path = '/api/v1/payments'
const headers = new Headers()
@@ -54,7 +55,7 @@ async function postPayment (bolt11, { url, adminKey }) {
const body = JSON.stringify({ bolt11, out: true })
- const res = await fetch(url + path, { method: 'POST', headers, body })
+ const res = await fetchWithTimeout(url + path, { method: 'POST', headers, body, signal })
assertContentTypeJson(res)
if (!res.ok) {
@@ -66,7 +67,7 @@ async function postPayment (bolt11, { url, adminKey }) {
return payment
}
-async function getPayment (paymentHash, { url, adminKey }) {
+async function getPayment (paymentHash, { url, adminKey }, { signal }) {
const path = `/api/v1/payments/${paymentHash}`
const headers = new Headers()
@@ -74,7 +75,7 @@ async function getPayment (paymentHash, { url, adminKey }) {
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey)
- const res = await fetch(url + path, { method: 'GET', headers })
+ const res = await fetchWithTimeout(url + path, { method: 'GET', headers, signal })
assertContentTypeJson(res)
if (!res.ok) {
diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js
index 72099055b..5bc728265 100644
--- a/wallets/lnbits/server.js
+++ b/wallets/lnbits/server.js
@@ -1,3 +1,5 @@
+import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants'
+import { FetchTimeoutError } from '@/lib/fetch'
import { msatsToSats } from '@/lib/format'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson } from '@/lib/url'
@@ -5,13 +7,14 @@ import fetch from 'cross-fetch'
export * from '@/wallets/lnbits'
-export async function testCreateInvoice ({ url, invoiceKey }) {
- return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey })
+export async function testCreateInvoice ({ url, invoiceKey }, { signal }) {
+ return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
- { url, invoiceKey }) {
+ { url, invoiceKey },
+ { signal }) {
const path = '/api/v1/payments'
const headers = new Headers()
@@ -38,12 +41,23 @@ export async function createInvoice (
hostname = 'lnbits:5000'
}
- const res = await fetch(`${agent.protocol}//${hostname}${path}`, {
- method: 'POST',
- headers,
- agent,
- body
- })
+ let res
+ try {
+ res = await fetch(`${agent.protocol}//${hostname}${path}`, {
+ method: 'POST',
+ headers,
+ agent,
+ body,
+ signal
+ })
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ // XXX node-fetch doesn't throw our custom TimeoutError but throws a generic error so we have to handle that manually.
+ // see https://github.com/node-fetch/node-fetch/issues/1462
+ throw new FetchTimeoutError('POST', url, WALLET_CREATE_INVOICE_TIMEOUT_MS)
+ }
+ throw err
+ }
assertContentTypeJson(res)
if (!res.ok) {
diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js
index b7dc13fad..f6fc3829a 100644
--- a/wallets/nwc/client.js
+++ b/wallets/nwc/client.js
@@ -1,21 +1,14 @@
-import { nwcCall, supportedMethods } from '@/wallets/nwc'
+import { supportedMethods, nwcTryRun } from '@/wallets/nwc'
export * from '@/wallets/nwc'
-export async function testSendPayment ({ nwcUrl }, { logger }) {
- const timeout = 15_000
-
- const supported = await supportedMethods(nwcUrl, { logger, timeout })
+export async function testSendPayment ({ nwcUrl }, { signal }) {
+ const supported = await supportedMethods(nwcUrl, { signal })
if (!supported.includes('pay_invoice')) {
throw new Error('pay_invoice not supported')
}
}
-export async function sendPayment (bolt11, { nwcUrl }, { logger }) {
- const result = await nwcCall({
- nwcUrl,
- method: 'pay_invoice',
- params: { invoice: bolt11 }
- },
- { logger })
+export async function sendPayment (bolt11, { nwcUrl }, { signal }) {
+ const result = await nwcTryRun(nwc => nwc.payInvoice(bolt11), { nwcUrl }, { signal })
return result.preimage
}
diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js
index 5bab23009..3d0e212cf 100644
--- a/wallets/nwc/index.js
+++ b/wallets/nwc/index.js
@@ -1,7 +1,10 @@
-import { Relay } from '@/lib/nostr'
-import { parseNwcUrl } from '@/lib/url'
+import Nostr from '@/lib/nostr'
import { string } from '@/lib/yup'
-import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
+import { parseNwcUrl } from '@/lib/url'
+import { NDKNwc } from '@nostr-dev-kit/ndk'
+import { TimeoutError } from '@/lib/time'
+
+const NWC_CONNECT_TIMEOUT_MS = 15_000
export const name = 'nwc'
export const walletType = 'NWC'
@@ -33,61 +36,61 @@ export const card = {
subtitle: 'use Nostr Wallet Connect for payments'
}
-export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) {
- const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
-
- const relay = await Relay.connect(relayUrl, { timeout })
- logger?.ok(`connected to ${relayUrl}`)
-
+async function getNwc (nwcUrl, { signal }) {
+ const ndk = new Nostr().ndk
+ const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl)
+ const nwc = new NDKNwc({
+ ndk,
+ pubkey: walletPubkey,
+ relayUrls,
+ secret
+ })
+
+ // TODO: support AbortSignal
try {
- const payload = { method, params }
- const encrypted = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
-
- const request = finalizeEvent({
- kind: 23194,
- created_at: Math.floor(Date.now() / 1000),
- tags: [['p', walletPubkey]],
- content: encrypted
- }, secret)
-
- // we need to subscribe to the response before publishing the request
- // since NWC events are ephemeral (20000 <= kind < 30000)
- const subscription = relay.fetch([{
- kinds: [23195],
- authors: [walletPubkey],
- '#e': [request.id]
- }], { timeout })
-
- await relay.publish(request, { timeout })
-
- logger?.info(`published ${method} request`)
-
- logger?.info(`waiting for ${method} response ...`)
-
- const [response] = await subscription
-
- if (!response) {
- throw new Error(`no ${method} response`)
+ await nwc.blockUntilReady(NWC_CONNECT_TIMEOUT_MS)
+ } catch (err) {
+ if (err.message === 'Timeout') {
+ throw new TimeoutError(NWC_CONNECT_TIMEOUT_MS)
}
+ throw err
+ }
- logger?.ok(`${method} response received`)
-
- if (!verifyEvent(response)) throw new Error(`invalid ${method} response: failed to verify`)
-
- const decrypted = await nip04.decrypt(secret, walletPubkey, response.content)
- const content = JSON.parse(decrypted)
-
- if (content.error) throw new Error(content.error.message)
- if (content.result) return content.result
+ return nwc
+}
- throw new Error(`invalid ${method} response: missing error or result`)
+/**
+ * Run a nwc function and throw if it errors
+ * (workaround to handle ambiguous NDK error handling)
+ * @param {function} fun - the nwc function to run
+ * @returns - the result of the nwc function
+ */
+export async function nwcTryRun (fun, { nwcUrl }, { signal }) {
+ let nwc
+ try {
+ nwc = await getNwc(nwcUrl, { signal })
+ const { error, result } = await fun(nwc)
+ if (error) throw new Error(error.message || error.code)
+ return result
+ } catch (e) {
+ if (e.error) throw new Error(e.error.message || e.error.code)
+ throw e
} finally {
- relay?.close()
- logger?.info(`closed connection to ${relayUrl}`)
+ if (nwc) close(nwc)
+ }
+}
+
+/**
+ * Close all relay connections of the NDKNwc instance
+ * @param {NDKNwc} nwc
+ */
+async function close (nwc) {
+ for (const relay of nwc.relaySet.relays) {
+ nwc.ndk.pool.removeRelay(relay.url)
}
}
-export async function supportedMethods (nwcUrl, { logger, timeout } = {}) {
- const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout })
+export async function supportedMethods (nwcUrl, { signal }) {
+ const result = await nwcTryRun(nwc => nwc.getInfo(), { nwcUrl }, { signal })
return result.methods
}
diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js
index 9a8b06e12..6fb4c82c7 100644
--- a/wallets/nwc/server.js
+++ b/wallets/nwc/server.js
@@ -1,11 +1,8 @@
-import { withTimeout } from '@/lib/time'
-import { nwcCall, supportedMethods } from '@/wallets/nwc'
+import { supportedMethods, nwcTryRun } from '@/wallets/nwc'
export * from '@/wallets/nwc'
-export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) {
- const timeout = 15_000
-
- const supported = await supportedMethods(nwcUrlRecv, { logger, timeout })
+export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) {
+ const supported = await supportedMethods(nwcUrlRecv, { signal })
const supports = (method) => supported.includes(method)
@@ -20,20 +17,13 @@ export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) {
}
}
- return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { logger }), timeout)
+ return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal })
}
-export async function createInvoice (
- { msats, description, expiry },
- { nwcUrlRecv }, { logger }) {
- const result = await nwcCall({
- nwcUrl: nwcUrlRecv,
- method: 'make_invoice',
- params: {
- amount: msats,
- description,
- expiry
- }
- }, { logger })
+export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { signal }) {
+ const result = await nwcTryRun(
+ nwc => nwc.sendReq('make_invoice', { amount: msats, description, expiry }),
+ { nwcUrl: nwcUrlRecv }, { signal }
+ )
return result.invoice
}
diff --git a/wallets/payment.js b/wallets/payment.js
index 157e06ead..043d57f89 100644
--- a/wallets/payment.js
+++ b/wallets/payment.js
@@ -2,17 +2,19 @@ import { useCallback } from 'react'
import { useSendWallets } from '@/wallets'
import { formatSats } from '@/lib/format'
import useInvoice from '@/components/use-invoice'
-import { FAST_POLL_INTERVAL } from '@/lib/constants'
+import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
- WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
+ WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
} from '@/wallets/errors'
import { canSend } from './common'
import { useWalletLoggerFactory } from './logger'
+import { timeoutSignal, withTimeout } from '@/lib/time'
export function useWalletPayment () {
const wallets = useSendWallets()
const sendPayment = useSendPayment()
+ const loggerFactory = useWalletLoggerFactory()
const invoiceHelper = useInvoice()
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
@@ -24,44 +26,71 @@ export function useWalletPayment () {
throw new WalletsNotAvailableError()
}
- for (const [i, wallet] of wallets.entries()) {
+ for (let i = 0; i < wallets.length; i++) {
+ const wallet = wallets[i]
+ const logger = loggerFactory(wallet)
+
+ const { bolt11 } = latestInvoice
const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice)
+
+ const walletPromise = sendPayment(wallet, logger, latestInvoice)
+ const pollPromise = controller.wait(waitFor)
+
try {
return await new Promise((resolve, reject) => {
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately.
// that's why we separately check if we received the payment with the invoice controller.
- sendPayment(wallet, latestInvoice).catch(reject)
- controller.wait(waitFor)
- .then(resolve)
- .catch(reject)
+ walletPromise.catch(reject)
+ pollPromise.then(resolve).catch(reject)
})
} catch (err) {
- // cancel invoice to make sure it cannot be paid later and create new invoice to retry.
- // we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
- if (err instanceof WalletPaymentError) {
- await invoiceHelper.cancel(latestInvoice)
+ let paymentError = err
+ const message = `payment failed: ${paymentError.reason ?? paymentError.message}`
+
+ if (!(paymentError instanceof WalletError)) {
+ // payment failed for some reason unrelated to wallets (ie invoice expired or was canceled).
+ // bail out of attempting wallets.
+ logger.error(message, { bolt11 })
+ throw paymentError
+ }
+
+ // at this point, paymentError is always a wallet error,
+ // we just need to distinguish between receiver and sender errors
- // is there another wallet to try?
- const lastAttempt = i === wallets.length - 1
- if (!lastAttempt) {
- latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
+ try {
+ // we need to poll one more time to check for failed forwards since sender wallet errors
+ // can be caused by them which we want to handle as receiver errors, not sender errors.
+ await invoiceHelper.isInvoice(latestInvoice, waitFor)
+ } catch (err) {
+ if (err instanceof WalletError) {
+ paymentError = err
}
}
- // TODO: receiver fallbacks
- //
- // if payment failed because of the receiver, we should use the same wallet again.
- // if (err instanceof ReceiverError) { ... }
+ if (paymentError instanceof WalletReceiverError) {
+ // if payment failed because of the receiver, use the same wallet again
+ // and log this as info, not error
+ logger.info('failed to forward payment to receiver, retrying with new invoice', { bolt11 })
+ i -= 1
+ } else if (paymentError instanceof WalletPaymentError) {
+ // only log payment errors, not configuration errors
+ logger.error(message, { bolt11 })
+ }
+
+ if (paymentError instanceof WalletPaymentError) {
+ // if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry.
+ await invoiceHelper.cancel(latestInvoice)
+ }
- // try next wallet if the payment failed because of the wallet
- // and not because it expired or was canceled
- if (err instanceof WalletError) {
- aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
- continue
+ // only create a new invoice if we will try to pay with a wallet again
+ const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1
+ if (retry) {
+ latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}
- // payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
- throw err
+ aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice)
+
+ continue
} finally {
controller.stop()
}
@@ -111,11 +140,7 @@ function invoiceController (inv, isInvoice) {
}
function useSendPayment () {
- const factory = useWalletLoggerFactory()
-
- return useCallback(async (wallet, invoice) => {
- const logger = factory(wallet)
-
+ return useCallback(async (wallet, logger, invoice) => {
if (!wallet.config.enabled) {
throw new WalletNotEnabledError(wallet.def.name)
}
@@ -128,12 +153,17 @@ function useSendPayment () {
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
try {
- const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
+ const preimage = await withTimeout(
+ wallet.def.sendPayment(bolt11, wallet.config, {
+ logger,
+ signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
+ }),
+ WALLET_SEND_PAYMENT_TIMEOUT_MS)
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) {
+ // we don't log the error here since we want to handle receiver errors separately
const message = err.message || err.toString?.()
- logger.error(`payment failed: ${message}`, { bolt11 })
throw new WalletSenderError(wallet.def.name, invoice, message)
}
- }, [factory])
+ }, [])
}
diff --git a/wallets/phoenixd/client.js b/wallets/phoenixd/client.js
index 703ef8dfe..f923aaf4a 100644
--- a/wallets/phoenixd/client.js
+++ b/wallets/phoenixd/client.js
@@ -1,8 +1,9 @@
+import { fetchWithTimeout } from '@/lib/fetch'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/phoenixd'
-export async function testSendPayment (config, { logger }) {
+export async function testSendPayment (config, { logger, signal }) {
// TODO:
// Not sure which endpoint to call to test primary password
// see https://phoenix.acinq.co/server/api
@@ -10,7 +11,7 @@ export async function testSendPayment (config, { logger }) {
}
-export async function sendPayment (bolt11, { url, primaryPassword }) {
+export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) {
// https://phoenix.acinq.co/server/api#pay-bolt11-invoice
const path = '/payinvoice'
@@ -21,10 +22,11 @@ export async function sendPayment (bolt11, { url, primaryPassword }) {
const body = new URLSearchParams()
body.append('invoice', bolt11)
- const res = await fetch(url + path, {
+ const res = await fetchWithTimeout(url + path, {
method: 'POST',
headers,
- body
+ body,
+ signal
})
assertResponseOk(res)
diff --git a/wallets/phoenixd/server.js b/wallets/phoenixd/server.js
index 67f324d22..aceefd075 100644
--- a/wallets/phoenixd/server.js
+++ b/wallets/phoenixd/server.js
@@ -1,17 +1,20 @@
+import { fetchWithTimeout } from '@/lib/fetch'
import { msatsToSats } from '@/lib/format'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/phoenixd'
-export async function testCreateInvoice ({ url, secondaryPassword }) {
+export async function testCreateInvoice ({ url, secondaryPassword }, { signal }) {
return await createInvoice(
{ msats: 1000, description: 'SN test invoice', expiry: 1 },
- { url, secondaryPassword })
+ { url, secondaryPassword },
+ { signal })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
- { url, secondaryPassword }
+ { url, secondaryPassword },
+ { signal }
) {
// https://phoenix.acinq.co/server/api#create-bolt11-invoice
const path = '/createinvoice'
@@ -24,10 +27,11 @@ export async function createInvoice (
body.append('description', description)
body.append('amountSat', msatsToSats(msats))
- const res = await fetch(url + path, {
+ const res = await fetchWithTimeout(url + path, {
method: 'POST',
headers,
- body
+ body,
+ signal
})
assertResponseOk(res)
diff --git a/wallets/server.js b/wallets/server.js
index c329d7670..f14e9fb36 100644
--- a/wallets/server.js
+++ b/wallets/server.js
@@ -15,8 +15,8 @@ import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format'
-import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
-import { withTimeout } from '@/lib/time'
+import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants'
+import { timeoutSignal, withTimeout } from '@/lib/time'
import { canReceive } from './common'
import wrapInvoice from './wrap'
@@ -24,9 +24,9 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
const MAX_PENDING_INVOICES_PER_WALLET = 25
-export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
+export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { predecessorId, models }) {
// get the wallets in order of priority
- const wallets = await getInvoiceableWallets(userId, { models })
+ const wallets = await getInvoiceableWallets(userId, { predecessorId, models })
msats = toPositiveNumber(msats)
@@ -81,7 +81,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa
export async function createWrappedInvoice (userId,
{ msats, feePercent, description, descriptionHash, expiry = 360 },
- { models, me, lnd }) {
+ { predecessorId, models, me, lnd }) {
let logger, bolt11
try {
const { invoice, wallet } = await createInvoice(userId, {
@@ -90,7 +90,7 @@ export async function createWrappedInvoice (userId,
description,
descriptionHash,
expiry
- }, { models })
+ }, { predecessorId, models })
logger = walletLogger({ wallet, models })
bolt11 = invoice
@@ -110,18 +110,48 @@ export async function createWrappedInvoice (userId,
}
}
-export async function getInvoiceableWallets (userId, { models }) {
- const wallets = await models.wallet.findMany({
- where: { userId, enabled: true },
- include: {
- user: true
- },
- orderBy: [
- { priority: 'asc' },
- // use id as tie breaker (older wallet first)
- { id: 'asc' }
- ]
- })
+export async function getInvoiceableWallets (userId, { predecessorId, models }) {
+ // filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices.
+ // the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it
+ // so it has not been updated yet.
+ // if predecessorId is not provided, the subquery will be empty and thus no wallets are filtered out.
+ const wallets = await models.$queryRaw`
+ SELECT
+ "Wallet".*,
+ jsonb_build_object(
+ 'id', "users"."id",
+ 'hideInvoiceDesc', "users"."hideInvoiceDesc"
+ ) AS "user"
+ FROM "Wallet"
+ JOIN "users" ON "users"."id" = "Wallet"."userId"
+ WHERE
+ "Wallet"."userId" = ${userId}
+ AND "Wallet"."enabled" = true
+ AND "Wallet"."id" NOT IN (
+ WITH RECURSIVE "Retries" AS (
+ -- select the current failed invoice that we are currently retrying
+ -- this failed invoice will be used to start the recursion
+ SELECT "Invoice"."id", "Invoice"."predecessorId"
+ FROM "Invoice"
+ WHERE "Invoice"."id" = ${predecessorId} AND "Invoice"."actionState" = 'FAILED'
+
+ UNION ALL
+
+ -- recursive part: use predecessorId to select the previous invoice that failed in the chain
+ -- until there is no more previous invoice
+ SELECT "Invoice"."id", "Invoice"."predecessorId"
+ FROM "Invoice"
+ JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId"
+ WHERE "Invoice"."actionState" = 'RETRYING'
+ )
+ SELECT
+ "InvoiceForward"."walletId"
+ FROM "Retries"
+ JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id"
+ JOIN "Withdrawl" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
+ WHERE "Withdrawl"."status" IS DISTINCT FROM 'CONFIRMED'
+ )
+ ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC`
const walletsWithDefs = wallets.map(wallet => {
const w = walletDefs.find(w => w.walletType === wallet.type)
@@ -171,6 +201,9 @@ async function walletCreateInvoice ({ wallet, def }, {
expiry
},
wallet.wallet,
- { logger }
- ), 10_000)
+ {
+ logger,
+ signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
+ }
+ ), WALLET_CREATE_INVOICE_TIMEOUT_MS)
}
diff --git a/worker/earn.js b/worker/earn.js
index 630342597..019cf9bb1 100644
--- a/worker/earn.js
+++ b/worker/earn.js
@@ -1,6 +1,5 @@
import { notifyEarner } from '@/lib/webPush'
import createPrisma from '@/lib/create-prisma'
-import { proportions } from '@/lib/madness'
import { SN_NO_REWARDS_IDS } from '@/lib/constants'
const TOTAL_UPPER_BOUND_MSATS = 1_000_000_000
@@ -40,18 +39,19 @@ export async function earn ({ name }) {
/*
How earnings (used to) work:
- 1/3: top 21% posts over last 36 hours, scored on a relative basis
- 1/3: top 21% comments over last 36 hours, scored on a relative basis
+ 1/3: top 50% posts over last 36 hours, scored on a relative basis
+ 1/3: top 50% comments over last 36 hours, scored on a relative basis
1/3: top upvoters of top posts/comments, scored on:
- their trust
- how much they tipped
- how early they upvoted it
- how the post/comment scored
- Now: 80% of earnings go to top 100 stackers by value, and 10% each to their forever and one day referrers
+ Now: 80% of earnings go to top stackers by relative value, and 10% each to their forever and one day referrers
*/
// get earners { userId, id, type, rank, proportion, foreverReferrerId, oneDayReferrerId }
+ // has to earn at least 125000 msats to be eligible (so that they get at least 1 sat after referrals)
const earners = await models.$queryRaw`
WITH earners AS (
SELECT users.id AS "userId", users."referrerId" AS "foreverReferrerId",
@@ -63,8 +63,8 @@ export async function earn ({ name }) {
'day') uv
JOIN users ON users.id = uv.id
WHERE NOT (users.id = ANY (${SN_NO_REWARDS_IDS}))
+ AND uv.proportion >= 0.0000125
ORDER BY proportion DESC
- LIMIT 100
)
SELECT earners.*,
COALESCE(
@@ -86,10 +86,10 @@ export async function earn ({ name }) {
let total = 0
const notifications = {}
- for (const [i, earner] of earners.entries()) {
+ for (const [, earner] of earners.entries()) {
const foreverReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
let oneDayReferrerEarnings = Math.floor(parseFloat(earner.proportion * sum * 0.1)) // 10% of earnings
- const earnerEarnings = Math.floor(parseFloat(proportions[i] * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings
+ const earnerEarnings = Math.floor(parseFloat(earner.proportion * sum)) - foreverReferrerEarnings - oneDayReferrerEarnings
total += earnerEarnings + foreverReferrerEarnings + oneDayReferrerEarnings
if (total > sum) {
@@ -108,7 +108,7 @@ export async function earn ({ name }) {
'oneDayReferrer', earner.oneDayReferrerId,
'oneDayReferrerEarnings', oneDayReferrerEarnings)
- if (earnerEarnings > 0) {
+ if (earnerEarnings > 1000) {
stmts.push(...earnStmts({
msats: earnerEarnings,
userId: earner.userId,
@@ -140,7 +140,7 @@ export async function earn ({ name }) {
}
}
- if (earner.foreverReferrerId && foreverReferrerEarnings > 0) {
+ if (earner.foreverReferrerId && foreverReferrerEarnings > 1000) {
stmts.push(...earnStmts({
msats: foreverReferrerEarnings,
userId: earner.foreverReferrerId,
@@ -153,7 +153,7 @@ export async function earn ({ name }) {
oneDayReferrerEarnings += foreverReferrerEarnings
}
- if (earner.oneDayReferrerId && oneDayReferrerEarnings > 0) {
+ if (earner.oneDayReferrerId && oneDayReferrerEarnings > 1000) {
stmts.push(...earnStmts({
msats: oneDayReferrerEarnings,
userId: earner.oneDayReferrerId,
diff --git a/worker/index.js b/worker/index.js
index 5543e289c..16d48c59c 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -38,6 +38,12 @@ import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
+// WebSocket polyfill
+import ws from 'isomorphic-ws'
+if (typeof WebSocket === 'undefined') {
+ global.WebSocket = ws
+}
+
async function work () {
const boss = new PgBoss(process.env.DATABASE_URL)
const models = createPrisma({
diff --git a/worker/nostr.js b/worker/nostr.js
index 7dd932c95..fe2201262 100644
--- a/worker/nostr.js
+++ b/worker/nostr.js
@@ -1,5 +1,4 @@
-import { signId, calculateId, getPublicKey } from 'nostr'
-import { Relay } from '@/lib/nostr'
+import Nostr from '@/lib/nostr'
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
@@ -40,26 +39,18 @@ export async function nip57 ({ data: { hash }, boss, lnd, models }) {
const e = {
kind: 9735,
- pubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY),
created_at: Math.floor(new Date(inv.confirmedAt).getTime() / 1000),
content: '',
tags
}
- e.id = await calculateId(e)
- e.sig = await signId(process.env.NOSTR_PRIVATE_KEY, e.id)
console.log('zap note', e, relays)
- await Promise.allSettled(
- relays.map(async r => {
- const timeout = 1000
- const relay = await Relay.connect(r, { timeout })
- try {
- await relay.publish(e, { timeout })
- } finally {
- relay.close()
- }
- })
- )
+ const signer = Nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY })
+ await Nostr.publish(e, {
+ relays,
+ signer,
+ timeout: 1000
+ })
} catch (e) {
console.log(e)
}
diff --git a/worker/paidAction.js b/worker/paidAction.js
index 70396ddbf..9b3ecb5a6 100644
--- a/worker/paidAction.js
+++ b/worker/paidAction.js
@@ -1,7 +1,7 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd'
import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet'
-import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
+import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { Prisma } from '@prisma/client'
@@ -270,6 +270,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode
request: bolt11,
max_fee_mtokens: String(maxFeeMsats),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
+ confidence: LND_PATHFINDING_TIME_PREF_PPM,
max_timeout_height: maxTimeoutHeight
}).catch(console.error)
}
@@ -316,13 +317,11 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
}, { models, lnd, boss })
if (transitionedInvoice) {
- const { bolt11, msatsPaid, msatsFeePaid } = transitionedInvoice.invoiceForward.withdrawl
- // the amount we paid includes the fee so we need to subtract it to get the amount received
- const received = Number(msatsPaid) - Number(msatsFeePaid)
+ const { bolt11, msatsPaid } = transitionedInvoice.invoiceForward.withdrawl
const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models })
logger.ok(
- `↙ payment received: ${formatSats(msatsToSats(received))}`,
+ `↙ payment received: ${formatSats(msatsToSats(Number(msatsPaid)))}`,
{
bolt11,
preimage: transitionedInvoice.preimage