Compare commits
5 Commits
555601c7de
...
c4a96af5d3
Author | SHA1 | Date | |
---|---|---|---|
|
c4a96af5d3 | ||
|
df62cfb28c | ||
|
67d71ef0c8 | ||
|
789d7626f7 | ||
|
bc94ec7d28 |
@ -1,6 +1,6 @@
|
|||||||
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
|
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import { USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
|
||||||
import { createHmac } from '../resolvers/wallet'
|
import { createHmac } from '../resolvers/wallet'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import * as ITEM_CREATE from './itemCreate'
|
import * as ITEM_CREATE from './itemCreate'
|
||||||
@ -215,13 +215,30 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INVOICE_EXPIRE_SECS = 600
|
const INVOICE_EXPIRE_SECS = 600
|
||||||
|
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
|
||||||
|
|
||||||
export async function createLightningInvoice (actionType, args, context) {
|
export async function createLightningInvoice (actionType, args, context) {
|
||||||
// if the action has an invoiceable peer, we'll create a peer invoice
|
// if the action has an invoiceable peer, we'll create a peer invoice
|
||||||
// wrap it, and return the wrapped invoice
|
// wrap it, and return the wrapped invoice
|
||||||
const { cost, models, lnd } = context
|
const { cost, models, lnd, me } = context
|
||||||
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
|
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
|
||||||
|
|
||||||
|
// count pending invoices and bail if we're over the limit
|
||||||
|
const pendingInvoices = await models.invoice.count({
|
||||||
|
where: {
|
||||||
|
userId: me?.id ?? USER_ID.anon,
|
||||||
|
actionState: {
|
||||||
|
// not in a terminal state. Note: null isn't counted by prisma
|
||||||
|
notIn: PAID_ACTION_TERMINAL_STATES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('pending paid actions', pendingInvoices)
|
||||||
|
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
|
||||||
|
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
|
||||||
|
}
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
const description = await paidActions[actionType].describe(args, context)
|
const description = await paidActions[actionType].describe(args, context)
|
||||||
|
@ -21,8 +21,10 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
|
|||||||
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
||||||
+ ${satsToMsats(boost)}::INTEGER as cost`
|
+ ${satsToMsats(boost)}::INTEGER as cost`
|
||||||
|
|
||||||
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance
|
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
|
||||||
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!me && cost > me?.msats
|
// cost must be greater than user's balance, and user has not disabled freebies
|
||||||
|
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!me &&
|
||||||
|
cost > me?.msats && !me?.disableFreebies
|
||||||
|
|
||||||
return freebie ? BigInt(0) : BigInt(cost)
|
return freebie ? BigInt(0) : BigInt(cost)
|
||||||
}
|
}
|
||||||
|
@ -622,6 +622,19 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
|
disableFreebies: async (parent, args, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// disable freebies if it hasn't been set yet
|
||||||
|
await models.user.update({
|
||||||
|
where: { id: me.id, disableFreebies: null },
|
||||||
|
data: { disableFreebies: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
setName: async (parent, data, { me, models }) => {
|
setName: async (parent, data, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
@ -32,7 +32,7 @@ function injectResolvers (resolvers) {
|
|||||||
|
|
||||||
return await upsertWallet({
|
return await upsertWallet({
|
||||||
wallet: { field: w.walletField, type: w.walletType },
|
wallet: { field: w.walletField, type: w.walletType },
|
||||||
testConnectServer: (data) => w.testConnectServer(data, { me, models })
|
testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models })
|
||||||
}, { settings, data }, { me, models })
|
}, { settings, data }, { me, models })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -571,15 +571,15 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function upsertWallet (
|
async function upsertWallet (
|
||||||
{ wallet, testConnectServer }, { settings, data }, { me, models }) {
|
{ wallet, testCreateInvoice }, { settings, data }, { me, models }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
}
|
}
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
if (testConnectServer) {
|
if (testCreateInvoice) {
|
||||||
try {
|
try {
|
||||||
await testConnectServer(data)
|
await testCreateInvoice(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
|
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
|
||||||
|
@ -43,6 +43,7 @@ export default gql`
|
|||||||
toggleMute(id: ID): User
|
toggleMute(id: ID): User
|
||||||
generateApiKey(id: ID!): String
|
generateApiKey(id: ID!): String
|
||||||
deleteApiKey(id: ID!): User
|
deleteApiKey(id: ID!): User
|
||||||
|
disableFreebies: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
@ -72,6 +73,7 @@ export default gql`
|
|||||||
noReferralLinks: Boolean!
|
noReferralLinks: Boolean!
|
||||||
fiatCurrency: String!
|
fiatCurrency: String!
|
||||||
satsFilter: Int!
|
satsFilter: Int!
|
||||||
|
disableFreebies: Boolean
|
||||||
hideBookmarks: Boolean!
|
hideBookmarks: Boolean!
|
||||||
hideCowboyHat: Boolean!
|
hideCowboyHat: Boolean!
|
||||||
hideGithub: Boolean!
|
hideGithub: Boolean!
|
||||||
@ -141,6 +143,7 @@ export default gql`
|
|||||||
noReferralLinks: Boolean!
|
noReferralLinks: Boolean!
|
||||||
fiatCurrency: String!
|
fiatCurrency: String!
|
||||||
satsFilter: Int!
|
satsFilter: Int!
|
||||||
|
disableFreebies: Boolean
|
||||||
greeterMode: Boolean!
|
greeterMode: Boolean!
|
||||||
hideBookmarks: Boolean!
|
hideBookmarks: Boolean!
|
||||||
hideCowboyHat: Boolean!
|
hideCowboyHat: Boolean!
|
||||||
|
@ -79,7 +79,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
|||||||
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
||||||
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
|
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
|
||||||
// freebies: there's only a base cost and we don't have enough sats
|
// freebies: there's only a base cost and we don't have enough sats
|
||||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
|
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total && !me?.privates?.disableFreebies
|
||||||
return {
|
return {
|
||||||
lines,
|
lines,
|
||||||
merge: mergeLineItems,
|
merge: mergeLineItems,
|
||||||
@ -88,7 +88,7 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
|||||||
setDisabled,
|
setDisabled,
|
||||||
free
|
free
|
||||||
}
|
}
|
||||||
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
}, [me?.privates?.sats, me?.privates?.disableFreebies, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeeButtonContext.Provider value={value}>
|
<FeeButtonContext.Provider value={value}>
|
||||||
|
@ -139,7 +139,8 @@ export default function useCrossposter () {
|
|||||||
</Button>
|
</Button>
|
||||||
</>,
|
</>,
|
||||||
{
|
{
|
||||||
onCancel: () => handleSkip()
|
onClose: () => handleSkip(),
|
||||||
|
autohide: false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -53,6 +53,7 @@ export const ME = gql`
|
|||||||
lnAddr
|
lnAddr
|
||||||
autoWithdrawMaxFeePercent
|
autoWithdrawMaxFeePercent
|
||||||
autoWithdrawThreshold
|
autoWithdrawThreshold
|
||||||
|
disableFreebies
|
||||||
}
|
}
|
||||||
optional {
|
optional {
|
||||||
isContributor
|
isContributor
|
||||||
@ -105,6 +106,7 @@ export const SETTINGS_FIELDS = gql`
|
|||||||
nostrRelays
|
nostrRelays
|
||||||
wildWestMode
|
wildWestMode
|
||||||
satsFilter
|
satsFilter
|
||||||
|
disableFreebies
|
||||||
nsfwMode
|
nsfwMode
|
||||||
authMethods {
|
authMethods {
|
||||||
lightning
|
lightning
|
||||||
|
@ -134,6 +134,9 @@ export const WALLET = gql`
|
|||||||
url
|
url
|
||||||
invoiceKey
|
invoiceKey
|
||||||
}
|
}
|
||||||
|
... on WalletNwc {
|
||||||
|
nwcUrlRecv
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,6 +170,9 @@ export const WALLET_BY_TYPE = gql`
|
|||||||
url
|
url
|
||||||
invoiceKey
|
invoiceKey
|
||||||
}
|
}
|
||||||
|
... on WalletNwc {
|
||||||
|
nwcUrlRecv
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
||||||
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
||||||
|
|
||||||
|
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
||||||
export const NOFOLLOW_LIMIT = 1000
|
export const NOFOLLOW_LIMIT = 1000
|
||||||
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
||||||
export const BOOST_MULT = 5000
|
export const BOOST_MULT = 5000
|
||||||
|
21
lib/nostr.js
21
lib/nostr.js
@ -2,6 +2,7 @@ import { bech32 } from 'bech32'
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import WebSocket from 'isomorphic-ws'
|
import WebSocket from 'isomorphic-ws'
|
||||||
import { callWithTimeout, withTimeout } from '@/lib/time'
|
import { callWithTimeout, withTimeout } from '@/lib/time'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
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]+$/
|
||||||
@ -29,11 +30,13 @@ export class Relay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = function (err) {
|
ws.onerror = function (err) {
|
||||||
console.error('websocket error: ' + err)
|
console.error('websocket error:', err.message)
|
||||||
this.error = err
|
this.error = err.message
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws = ws
|
this.ws = ws
|
||||||
|
this.url = relayUrl
|
||||||
|
this.error = null
|
||||||
}
|
}
|
||||||
|
|
||||||
static async connect (url, { timeout } = {}) {
|
static async connect (url, { timeout } = {}) {
|
||||||
@ -83,8 +86,6 @@ export class Relay {
|
|||||||
|
|
||||||
let listener
|
let listener
|
||||||
const ackPromise = new Promise((resolve, reject) => {
|
const ackPromise = new Promise((resolve, reject) => {
|
||||||
ws.send(JSON.stringify(['EVENT', event]))
|
|
||||||
|
|
||||||
listener = function onmessage (msg) {
|
listener = function onmessage (msg) {
|
||||||
const [type, eventId, accepted, reason] = JSON.parse(msg.data)
|
const [type, eventId, accepted, reason] = JSON.parse(msg.data)
|
||||||
|
|
||||||
@ -98,6 +99,8 @@ export class Relay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.addEventListener('message', listener)
|
ws.addEventListener('message', listener)
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(['EVENT', event]))
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -112,17 +115,15 @@ export class Relay {
|
|||||||
|
|
||||||
let listener
|
let listener
|
||||||
const ackPromise = new Promise((resolve, reject) => {
|
const ackPromise = new Promise((resolve, reject) => {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomBytes(16).toString('hex')
|
||||||
|
|
||||||
ws.send(JSON.stringify(['REQ', id, ...filter]))
|
|
||||||
|
|
||||||
const events = []
|
const events = []
|
||||||
let eose = false
|
let eose = false
|
||||||
|
|
||||||
listener = function onmessage (msg) {
|
listener = function onmessage (msg) {
|
||||||
const [type, eventId, event] = JSON.parse(msg.data)
|
const [type, subId, event] = JSON.parse(msg.data)
|
||||||
|
|
||||||
if (eventId !== id) return
|
if (subId !== id) return
|
||||||
|
|
||||||
if (type === 'EVENT') {
|
if (type === 'EVENT') {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
@ -150,6 +151,8 @@ export class Relay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.addEventListener('message', listener)
|
ws.addEventListener('message', listener)
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(['REQ', id, ...filter]))
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -159,6 +159,33 @@ addMethod(string, 'hex', function (msg) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
addMethod(string, 'nwcUrl', function () {
|
||||||
|
return this.test({
|
||||||
|
test: async (nwcUrl, context) => {
|
||||||
|
if (!nwcUrl) return true
|
||||||
|
|
||||||
|
// run validation in sequence to control order of errors
|
||||||
|
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
||||||
|
try {
|
||||||
|
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
||||||
|
let relayUrl, walletPubkey, secret
|
||||||
|
try {
|
||||||
|
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
|
||||||
|
} catch {
|
||||||
|
// invalid URL error. handle as if pubkey validation failed to not confuse user.
|
||||||
|
throw new Error('pubkey must be 64 hex chars')
|
||||||
|
}
|
||||||
|
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
||||||
|
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
||||||
|
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
||||||
|
} catch (err) {
|
||||||
|
return context.createError({ message: err.message })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const titleValidator = string().required('required').trim().max(
|
const titleValidator = string().required('required').trim().max(
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
||||||
@ -602,6 +629,7 @@ export const settingsSchema = object().shape({
|
|||||||
diagnostics: boolean(),
|
diagnostics: boolean(),
|
||||||
noReferralLinks: boolean(),
|
noReferralLinks: boolean(),
|
||||||
hideIsContributor: boolean(),
|
hideIsContributor: boolean(),
|
||||||
|
disableFreebies: boolean().nullable(),
|
||||||
satsFilter: intValidator.required('required').min(0, 'must be at least 0').max(1000, 'must be at most 1000'),
|
satsFilter: intValidator.required('required').min(0, 'must be at least 0').max(1000, 'must be at most 1000'),
|
||||||
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
||||||
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
|
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
|
||||||
@ -686,31 +714,22 @@ export const lnbitsSchema = object().shape({
|
|||||||
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
|
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
|
||||||
}, ['adminKey', 'invoiceKey'])
|
}, ['adminKey', 'invoiceKey'])
|
||||||
|
|
||||||
export const nwcSchema = object({
|
export const nwcSchema = object().shape({
|
||||||
nwcUrl: string()
|
nwcUrl: string().nwcUrl().when(['nwcUrlRecv'], ([nwcUrlRecv], schema) => {
|
||||||
.required('required')
|
if (!nwcUrlRecv) return schema.required('required if connection for receiving not set')
|
||||||
.test(async (nwcUrl, context) => {
|
return schema.test({
|
||||||
// run validation in sequence to control order of errors
|
test: nwcUrl => nwcUrl !== nwcUrlRecv,
|
||||||
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
message: 'connection for sending cannot be the same as for receiving'
|
||||||
try {
|
|
||||||
await string().required('required').validate(nwcUrl)
|
|
||||||
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
|
||||||
let relayUrl, walletPubkey, secret
|
|
||||||
try {
|
|
||||||
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
|
|
||||||
} catch {
|
|
||||||
// invalid URL error. handle as if pubkey validation failed to not confuse user.
|
|
||||||
throw new Error('pubkey must be 64 hex chars')
|
|
||||||
}
|
|
||||||
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
|
||||||
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
|
||||||
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
|
||||||
} catch (err) {
|
|
||||||
return context.createError({ message: err.message })
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
nwcUrlRecv: string().nwcUrl().when(['nwcUrl'], ([nwcUrl], schema) => {
|
||||||
|
if (!nwcUrl) return schema.required('required if connection for sending not set')
|
||||||
|
return schema.test({
|
||||||
|
test: nwcUrlRecv => nwcUrlRecv !== nwcUrl,
|
||||||
|
message: 'connection for receiving cannot be the same as for sending'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, ['nwcUrl', 'nwcUrlRecv'])
|
||||||
|
|
||||||
export const blinkSchema = object({
|
export const blinkSchema = object({
|
||||||
apiKey: string()
|
apiKey: string()
|
||||||
|
16
package-lock.json
generated
16
package-lock.json
generated
@ -57,7 +57,7 @@
|
|||||||
"node-s3-url-encode": "^0.0.4",
|
"node-s3-url-encode": "^0.0.4",
|
||||||
"nodemailer": "^6.9.6",
|
"nodemailer": "^6.9.6",
|
||||||
"nostr": "^0.2.8",
|
"nostr": "^0.2.8",
|
||||||
"nostr-tools": "^2.1.5",
|
"nostr-tools": "^2.7.2",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"opentimestamps": "^0.4.9",
|
"opentimestamps": "^0.4.9",
|
||||||
"page-metadata-parser": "^1.1.4",
|
"page-metadata-parser": "^1.1.4",
|
||||||
@ -3990,9 +3990,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "0.2.0",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||||
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
|
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
@ -14852,11 +14852,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nostr-tools": {
|
"node_modules/nostr-tools": {
|
||||||
"version": "2.1.5",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz",
|
||||||
"integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==",
|
"integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "0.2.0",
|
"@noble/ciphers": "^0.5.1",
|
||||||
"@noble/curves": "1.2.0",
|
"@noble/curves": "1.2.0",
|
||||||
"@noble/hashes": "1.3.1",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
"node-s3-url-encode": "^0.0.4",
|
"node-s3-url-encode": "^0.0.4",
|
||||||
"nodemailer": "^6.9.6",
|
"nodemailer": "^6.9.6",
|
||||||
"nostr": "^0.2.8",
|
"nostr": "^0.2.8",
|
||||||
"nostr-tools": "^2.1.5",
|
"nostr-tools": "^2.7.2",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"opentimestamps": "^0.4.9",
|
"opentimestamps": "^0.4.9",
|
||||||
"page-metadata-parser": "^1.1.4",
|
"page-metadata-parser": "^1.1.4",
|
||||||
|
@ -116,6 +116,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
tipRandomMin: settings?.tipRandomMin || 1,
|
tipRandomMin: settings?.tipRandomMin || 1,
|
||||||
tipRandomMax: settings?.tipRandomMax || 10,
|
tipRandomMax: settings?.tipRandomMax || 10,
|
||||||
turboTipping: settings?.turboTipping,
|
turboTipping: settings?.turboTipping,
|
||||||
|
disableFreebies: settings?.disableFreebies,
|
||||||
zapUndos: settings?.zapUndos || (settings?.tipDefault ? 100 * settings.tipDefault : 2100),
|
zapUndos: settings?.zapUndos || (settings?.tipDefault ? 100 * settings.tipDefault : 2100),
|
||||||
zapUndosEnabled: settings?.zapUndos !== null,
|
zapUndosEnabled: settings?.zapUndos !== null,
|
||||||
fiatCurrency: settings?.fiatCurrency || 'USD',
|
fiatCurrency: settings?.fiatCurrency || 'USD',
|
||||||
@ -252,6 +253,20 @@ export default function Settings ({ ssrData }) {
|
|||||||
required
|
required
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={
|
||||||
|
<div className='d-flex align-items-center'>disable freebies
|
||||||
|
<Info>
|
||||||
|
<p>Some posts and comments can be created without paying. However, that content has limited visibility.</p>
|
||||||
|
|
||||||
|
<p>If you disable freebies, you will always pay for your posts and comments and get standard visibility.</p>
|
||||||
|
|
||||||
|
<p>If you attach a sending wallet, we disable freebies for you unless you have checked/unchecked this value already.</p>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
name='disableFreebies'
|
||||||
|
/>
|
||||||
<div className='form-label'>notify me when ...</div>
|
<div className='form-label'>notify me when ...</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='I stack sats from posts and comments'
|
label='I stack sats from posts and comments'
|
||||||
|
23
prisma/migrations/20240817072013_nwc_recv/migration.sql
Normal file
23
prisma/migrations/20240817072013_nwc_recv/migration.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "WalletType" ADD VALUE 'NWC';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WalletNWC" (
|
||||||
|
"int" SERIAL NOT NULL,
|
||||||
|
"walletId" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"nwcUrlRecv" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WalletNWC_pkey" PRIMARY KEY ("int")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WalletNWC_walletId_key" ON "WalletNWC"("walletId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WalletNWC" ADD CONSTRAINT "WalletNWC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TRIGGER wallet_nwc_as_jsonb
|
||||||
|
AFTER INSERT OR UPDATE ON "WalletNWC"
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb();
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "disableFreebies" BOOLEAN;
|
@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `WalletLNbits` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- You are about to drop the column `int` on the `WalletLNbits` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WalletLNbits" RENAME COLUMN "int" TO "id";
|
||||||
|
|
||||||
|
UPDATE "Wallet"
|
||||||
|
SET wallet = to_jsonb("WalletLNbits")
|
||||||
|
FROM "WalletLNbits"
|
||||||
|
WHERE "Wallet".id = "WalletLNbits"."walletId";
|
@ -64,6 +64,7 @@ model User {
|
|||||||
zapUndos Int?
|
zapUndos Int?
|
||||||
imgproxyOnly Boolean @default(false)
|
imgproxyOnly Boolean @default(false)
|
||||||
hideWalletBalance Boolean @default(false)
|
hideWalletBalance Boolean @default(false)
|
||||||
|
disableFreebies Boolean?
|
||||||
referrerId Int?
|
referrerId Int?
|
||||||
nostrPubkey String?
|
nostrPubkey String?
|
||||||
greeterMode Boolean @default(false)
|
greeterMode Boolean @default(false)
|
||||||
@ -169,6 +170,7 @@ enum WalletType {
|
|||||||
LND
|
LND
|
||||||
CLN
|
CLN
|
||||||
LNBITS
|
LNBITS
|
||||||
|
NWC
|
||||||
}
|
}
|
||||||
|
|
||||||
model Wallet {
|
model Wallet {
|
||||||
@ -193,6 +195,7 @@ model Wallet {
|
|||||||
walletLND WalletLND?
|
walletLND WalletLND?
|
||||||
walletCLN WalletCLN?
|
walletCLN WalletCLN?
|
||||||
walletLNbits WalletLNbits?
|
walletLNbits WalletLNbits?
|
||||||
|
walletNWC WalletNWC?
|
||||||
withdrawals Withdrawl[]
|
withdrawals Withdrawl[]
|
||||||
InvoiceForward InvoiceForward[]
|
InvoiceForward InvoiceForward[]
|
||||||
|
|
||||||
@ -243,7 +246,7 @@ model WalletCLN {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WalletLNbits {
|
model WalletLNbits {
|
||||||
int Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
walletId Int @unique
|
walletId Int @unique
|
||||||
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@ -252,6 +255,15 @@ model WalletLNbits {
|
|||||||
invoiceKey String
|
invoiceKey String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WalletNWC {
|
||||||
|
int Int @id @default(autoincrement())
|
||||||
|
walletId Int @unique
|
||||||
|
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
nwcUrlRecv String
|
||||||
|
}
|
||||||
|
|
||||||
model Mute {
|
model Mute {
|
||||||
muterId Int
|
muterId Int
|
||||||
mutedId Int
|
mutedId Int
|
||||||
|
@ -153,9 +153,9 @@ The badges that are shown inside the card.
|
|||||||
|
|
||||||
A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client:
|
A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client:
|
||||||
|
|
||||||
- `testConnectClient: async (config, context) => Promise<void>`
|
- `testSendPayment: async (config, context) => Promise<void>`
|
||||||
|
|
||||||
`testConnectClient` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`.
|
`testSendPayment` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`.
|
||||||
|
|
||||||
How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key.
|
How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key.
|
||||||
|
|
||||||
@ -167,7 +167,7 @@ The `context` argument is an object. It makes the wallet logger for this wallet
|
|||||||
|
|
||||||
`sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet.
|
`sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet.
|
||||||
|
|
||||||
The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testConnectClient`.
|
The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testSendPayment`.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included:
|
> As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included:
|
||||||
@ -199,17 +199,17 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/
|
|||||||
|
|
||||||
A wallet that supports receiving must export the following properties in server.js which are only available if this wallet is imported on the server:
|
A wallet that supports receiving must export the following properties in server.js which are only available if this wallet is imported on the server:
|
||||||
|
|
||||||
- `testConnectServer: async (config, context) => Promise<void>`
|
- `testCreateInvoice: async (config, context) => Promise<void>`
|
||||||
|
|
||||||
`testConnectServer` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service).
|
`testCreateInvoice` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service).
|
||||||
|
|
||||||
It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving.
|
It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving.
|
||||||
|
|
||||||
Again, like `testConnectClient`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testConnectClient`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client).
|
Again, like `testSendPayment`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testSendPayment`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client).
|
||||||
|
|
||||||
- `createInvoice: async (amount: int, config, context) => Promise<bolt11: string>`
|
- `createInvoice: async (amount: int, config, context) => Promise<bolt11: string>`
|
||||||
|
|
||||||
`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `amount` specifies the amount in satoshis. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testConnectServer` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials.
|
`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `amount` specifies the amount in satoshis. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testCreateInvoice` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials.
|
||||||
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { galoyBlinkUrl } from 'wallets/blink'
|
import { galoyBlinkUrl } from 'wallets/blink'
|
||||||
export * from 'wallets/blink'
|
export * from 'wallets/blink'
|
||||||
|
|
||||||
export async function testConnectClient ({ apiKey, currency }, { logger }) {
|
export async function testSendPayment ({ apiKey, currency }, { logger }) {
|
||||||
currency = currency ? currency.toUpperCase() : 'BTC'
|
currency = currency ? currency.toUpperCase() : 'BTC'
|
||||||
logger.info('trying to fetch ' + currency + ' wallet')
|
logger.info('trying to fetch ' + currency + ' wallet')
|
||||||
await getWallet(apiKey, currency)
|
await getWallet(apiKey, currency)
|
||||||
|
@ -2,7 +2,7 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln'
|
|||||||
|
|
||||||
export * from 'wallets/cln'
|
export * from 'wallets/cln'
|
||||||
|
|
||||||
export const testConnectServer = async ({ socket, rune, cert }) => {
|
export const testCreateInvoice = async ({ socket, rune, cert }) => {
|
||||||
return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert })
|
return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { SSR } from '@/lib/constants'
|
|||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
|
||||||
import walletDefs from 'wallets/client'
|
import walletDefs from 'wallets/client'
|
||||||
import { gql, useApolloClient, useQuery } from '@apollo/client'
|
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||||
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
|
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||||
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
@ -25,6 +25,7 @@ export function useWallet (name) {
|
|||||||
const me = useMe()
|
const me = useMe()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||||
|
|
||||||
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
||||||
const { logger, deleteLogs } = useWalletLogger(wallet)
|
const { logger, deleteLogs } = useWalletLogger(wallet)
|
||||||
@ -36,6 +37,7 @@ export function useWallet (name) {
|
|||||||
const enablePayments = useCallback(() => {
|
const enablePayments = useCallback(() => {
|
||||||
enableWallet(name, me)
|
enableWallet(name, me)
|
||||||
logger.ok('payments enabled')
|
logger.ok('payments enabled')
|
||||||
|
disableFreebies().catch(console.error)
|
||||||
}, [name, me, logger])
|
}, [name, me, logger])
|
||||||
|
|
||||||
const disablePayments = useCallback(() => {
|
const disablePayments = useCallback(() => {
|
||||||
@ -77,17 +79,7 @@ export function useWallet (name) {
|
|||||||
}, [wallet, config, toaster])
|
}, [wallet, config, toaster])
|
||||||
|
|
||||||
const save = useCallback(async (newConfig) => {
|
const save = useCallback(async (newConfig) => {
|
||||||
// testConnectClient should log custom INFO and OK message
|
await saveConfig(newConfig, { logger })
|
||||||
// testConnectClient is optional since validation might happen during save on server
|
|
||||||
// TODO: add timeout
|
|
||||||
let validConfig
|
|
||||||
try {
|
|
||||||
validConfig = await wallet.testConnectClient?.(newConfig, { me, logger })
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(err.message)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
await saveConfig(validConfig ?? newConfig, { logger })
|
|
||||||
}, [saveConfig, me, logger])
|
}, [saveConfig, me, logger])
|
||||||
|
|
||||||
// delete is a reserved keyword
|
// delete is a reserved keyword
|
||||||
@ -197,6 +189,17 @@ function useConfig (wallet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
|
try {
|
||||||
|
// XXX: testSendPayment can return a new config (e.g. lnc)
|
||||||
|
const newerConfig = await wallet.testSendPayment?.(newConfig, { me, logger })
|
||||||
|
if (newerConfig) {
|
||||||
|
newClientConfig = newerConfig
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
setClientConfig(newClientConfig)
|
setClientConfig(newClientConfig)
|
||||||
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
|
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
|
||||||
if (newConfig.enabled) wallet.enablePayments()
|
if (newConfig.enabled) wallet.enablePayments()
|
||||||
@ -233,10 +236,16 @@ function isConfigured ({ fields, config }) {
|
|||||||
if (!config || !fields) return false
|
if (!config || !fields) return false
|
||||||
|
|
||||||
// a wallet is configured if all of its required fields are set
|
// a wallet is configured if all of its required fields are set
|
||||||
const val = fields.every(field => {
|
let val = fields.every(f => {
|
||||||
return field.optional ? true : !!config?.[field.name]
|
return f.optional ? true : !!config?.[f.name]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// however, a wallet is not configured if all fields are optional and none are set
|
||||||
|
// since that usually means that one of them is required
|
||||||
|
if (val && fields.length > 0) {
|
||||||
|
val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name]))
|
||||||
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { lnAddrOptions } from '@/lib/lnurl'
|
|||||||
|
|
||||||
export * from 'wallets/lightning-address'
|
export * from 'wallets/lightning-address'
|
||||||
|
|
||||||
export const testConnectServer = async ({ address }) => {
|
export const testCreateInvoice = async ({ address }) => {
|
||||||
return await createInvoice({ msats: 1000 }, { address })
|
return await createInvoice({ msats: 1000 }, { address })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export * from 'wallets/lnbits'
|
export * from 'wallets/lnbits'
|
||||||
|
|
||||||
export async function testConnectClient ({ url, adminKey, invoiceKey }, { logger }) {
|
export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) {
|
||||||
logger.info('trying to fetch wallet')
|
logger.info('trying to fetch wallet')
|
||||||
|
|
||||||
url = url.replace(/\/+$/, '')
|
url = url.replace(/\/+$/, '')
|
||||||
|
@ -2,7 +2,7 @@ import { msatsToSats } from '@/lib/format'
|
|||||||
|
|
||||||
export * from 'wallets/lnbits'
|
export * from 'wallets/lnbits'
|
||||||
|
|
||||||
export async function testConnectServer ({ url, invoiceKey }) {
|
export async function testCreateInvoice ({ url, invoiceKey }) {
|
||||||
return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey })
|
return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ async function disconnect (lnc, logger) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testConnectClient (credentials, { logger }) {
|
export async function testSendPayment (credentials, { logger }) {
|
||||||
let lnc
|
let lnc
|
||||||
try {
|
try {
|
||||||
lnc = await getLNC(credentials)
|
lnc = await getLNC(credentials)
|
||||||
|
@ -3,7 +3,7 @@ import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-serv
|
|||||||
|
|
||||||
export * from 'wallets/lnd'
|
export * from 'wallets/lnd'
|
||||||
|
|
||||||
export const testConnectServer = async ({ cert, macaroon, socket }) => {
|
export const testCreateInvoice = async ({ cert, macaroon, socket }) => {
|
||||||
return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket })
|
return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,74 +1,21 @@
|
|||||||
import { parseNwcUrl } from '@/lib/url'
|
import { nwcCall, supportedMethods } from 'wallets/nwc'
|
||||||
import { finalizeEvent, nip04 } from 'nostr-tools'
|
|
||||||
import { Relay } from '@/lib/nostr'
|
|
||||||
|
|
||||||
export * from 'wallets/nwc'
|
export * from 'wallets/nwc'
|
||||||
|
|
||||||
export async function testConnectClient ({ nwcUrl }, { logger }) {
|
export async function testSendPayment ({ nwcUrl }, { logger }) {
|
||||||
const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl)
|
const timeout = 15_000
|
||||||
|
|
||||||
logger.info(`requesting info event from ${relayUrl}`)
|
const supported = await supportedMethods(nwcUrl, { logger, timeout })
|
||||||
|
if (!supported.includes('pay_invoice')) {
|
||||||
const relay = await Relay.connect(relayUrl)
|
throw new Error('pay_invoice not supported')
|
||||||
logger.ok(`connected to ${relayUrl}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [info] = await relay.fetch([{
|
|
||||||
kinds: [13194],
|
|
||||||
authors: [walletPubkey]
|
|
||||||
}])
|
|
||||||
|
|
||||||
if (info) {
|
|
||||||
logger.ok(`received info event from ${relayUrl}`)
|
|
||||||
} else {
|
|
||||||
throw new Error('info event not found')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
relay?.close()
|
|
||||||
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 result = await nwcCall({
|
||||||
|
nwcUrl,
|
||||||
const relay = await Relay.connect(relayUrl)
|
|
||||||
logger.ok(`connected to ${relayUrl}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
method: 'pay_invoice',
|
method: 'pay_invoice',
|
||||||
params: { invoice: bolt11 }
|
params: { invoice: bolt11 }
|
||||||
}
|
},
|
||||||
const encrypted = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
|
{ logger })
|
||||||
|
return result.preimage
|
||||||
const request = finalizeEvent({
|
|
||||||
kind: 23194,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['p', walletPubkey]],
|
|
||||||
content: encrypted
|
|
||||||
}, secret)
|
|
||||||
await relay.publish(request)
|
|
||||||
|
|
||||||
const [response] = await relay.fetch([{
|
|
||||||
kinds: [23195],
|
|
||||||
authors: [walletPubkey],
|
|
||||||
'#e': [request.id]
|
|
||||||
}])
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error('no response')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { preimage: content.result.preimage }
|
|
||||||
|
|
||||||
throw new Error('invalid response')
|
|
||||||
} finally {
|
|
||||||
relay?.close()
|
|
||||||
logger.info(`closed connection to ${relayUrl}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { Relay } from '@/lib/nostr'
|
||||||
|
import { parseNwcUrl } from '@/lib/url'
|
||||||
import { nwcSchema } from '@/lib/validate'
|
import { nwcSchema } from '@/lib/validate'
|
||||||
|
import { finalizeEvent, nip04 } from 'nostr-tools'
|
||||||
|
|
||||||
export const name = 'nwc'
|
export const name = 'nwc'
|
||||||
|
|
||||||
@ -6,14 +9,80 @@ export const fields = [
|
|||||||
{
|
{
|
||||||
name: 'nwcUrl',
|
name: 'nwcUrl',
|
||||||
label: 'connection',
|
label: 'connection',
|
||||||
type: 'password'
|
type: 'password',
|
||||||
|
optional: 'for sending',
|
||||||
|
clientOnly: true,
|
||||||
|
editable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nwcUrlRecv',
|
||||||
|
label: 'connection',
|
||||||
|
type: 'password',
|
||||||
|
optional: 'for receiving',
|
||||||
|
serverOnly: true,
|
||||||
|
editable: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const card = {
|
export const card = {
|
||||||
title: 'NWC',
|
title: 'NWC',
|
||||||
subtitle: 'use Nostr Wallet Connect for payments',
|
subtitle: 'use Nostr Wallet Connect for payments',
|
||||||
badges: ['send only', 'budgetable']
|
badges: ['send & receive', 'budgetable']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = nwcSchema
|
export const fieldValidation = nwcSchema
|
||||||
|
|
||||||
|
export const walletType = 'NWC'
|
||||||
|
|
||||||
|
export const walletField = 'walletNWC'
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
await relay.publish(request, { timeout })
|
||||||
|
|
||||||
|
logger?.info(`published ${method} request`)
|
||||||
|
|
||||||
|
logger?.info('waiting for response ...')
|
||||||
|
const [response] = await relay.fetch([{
|
||||||
|
kinds: [23195],
|
||||||
|
authors: [walletPubkey],
|
||||||
|
'#e': [request.id]
|
||||||
|
}], { timeout })
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('no response')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.ok('response received')
|
||||||
|
|
||||||
|
const decrypted = await nip04.decrypt(secret, walletPubkey, response.content)
|
||||||
|
const content = JSON.parse(decrypted)
|
||||||
|
|
||||||
|
if (content.error) throw new Error(content.error.message)
|
||||||
|
if (content.result) return content.result
|
||||||
|
|
||||||
|
throw new Error('invalid response')
|
||||||
|
} finally {
|
||||||
|
relay?.close()
|
||||||
|
logger?.info(`closed connection to ${relayUrl}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function supportedMethods (nwcUrl, { logger, timeout } = {}) {
|
||||||
|
const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout })
|
||||||
|
return result.methods
|
||||||
|
}
|
||||||
|
39
wallets/nwc/server.js
Normal file
39
wallets/nwc/server.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { withTimeout } from '@/lib/time'
|
||||||
|
import { nwcCall, supportedMethods } from 'wallets/nwc'
|
||||||
|
export * from 'wallets/nwc'
|
||||||
|
|
||||||
|
export async function testCreateInvoice ({ nwcUrlRecv }) {
|
||||||
|
const timeout = 15_000
|
||||||
|
|
||||||
|
const supported = await supportedMethods(nwcUrlRecv, { timeout })
|
||||||
|
|
||||||
|
const supports = (method) => supported.includes(method)
|
||||||
|
|
||||||
|
if (!supports('make_invoice')) {
|
||||||
|
throw new Error('make_invoice not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mustNotSupport = ['pay_invoice', 'multi_pay_invoice', 'pay_keysend', 'multi_pay_keysend']
|
||||||
|
for (const method of mustNotSupport) {
|
||||||
|
if (supports(method)) {
|
||||||
|
throw new Error(`${method} must not be supported`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }), timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvoice (
|
||||||
|
{ msats, description, expiry },
|
||||||
|
{ nwcUrlRecv }) {
|
||||||
|
const result = await nwcCall({
|
||||||
|
nwcUrl: nwcUrlRecv,
|
||||||
|
method: 'make_invoice',
|
||||||
|
params: {
|
||||||
|
amount: msats,
|
||||||
|
description,
|
||||||
|
expiry
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result.invoice
|
||||||
|
}
|
@ -2,12 +2,15 @@ import * as lnd from 'wallets/lnd/server'
|
|||||||
import * as cln from 'wallets/cln/server'
|
import * as cln from 'wallets/cln/server'
|
||||||
import * as lnAddr from 'wallets/lightning-address/server'
|
import * as lnAddr from 'wallets/lightning-address/server'
|
||||||
import * as lnbits from 'wallets/lnbits/server'
|
import * as lnbits from 'wallets/lnbits/server'
|
||||||
|
import * as nwc from 'wallets/nwc/server'
|
||||||
import { addWalletLog } from '@/api/resolvers/wallet'
|
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||||
import walletDefs from 'wallets/server'
|
import walletDefs from 'wallets/server'
|
||||||
import { parsePaymentRequest } from 'ln-service'
|
import { parsePaymentRequest } from 'ln-service'
|
||||||
import { toPositiveNumber } from '@/lib/validate'
|
import { toPositiveNumber } from '@/lib/validate'
|
||||||
|
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
|
||||||
|
export default [lnd, cln, lnAddr, lnbits, nwc]
|
||||||
|
|
||||||
export default [lnd, cln, lnAddr, lnbits]
|
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 }, { models }) {
|
||||||
// get the wallets in order of priority
|
// get the wallets in order of priority
|
||||||
@ -44,6 +47,31 @@ export async function createInvoice (userId, { msats, description, descriptionHa
|
|||||||
throw new Error(`no ${walletType} wallet found`)
|
throw new Error(`no ${walletType} wallet found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check for pending withdrawals
|
||||||
|
const pendingWithdrawals = await models.withdrawl.count({
|
||||||
|
where: {
|
||||||
|
walletId: walletFull.id,
|
||||||
|
status: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// and pending forwards
|
||||||
|
const pendingForwards = await models.invoiceForward.count({
|
||||||
|
where: {
|
||||||
|
walletId: walletFull.id,
|
||||||
|
invoice: {
|
||||||
|
actionState: {
|
||||||
|
notIn: PAID_ACTION_TERMINAL_STATES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('pending invoices', pendingWithdrawals + pendingForwards)
|
||||||
|
if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
|
||||||
|
throw new Error('wallet has too many pending invoices')
|
||||||
|
}
|
||||||
|
|
||||||
const invoice = await createInvoice({
|
const invoice = await createInvoice({
|
||||||
msats,
|
msats,
|
||||||
description: wallet.user.hideInvoiceDesc ? undefined : description,
|
description: wallet.user.hideInvoiceDesc ? undefined : description,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
|
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
|
||||||
import { paidActions } from '@/api/paidAction'
|
import { paidActions } from '@/api/paidAction'
|
||||||
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
|
import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import { toPositiveNumber } from '@/lib/validate'
|
import { toPositiveNumber } from '@/lib/validate'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
@ -21,7 +21,7 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
|
|||||||
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
|
const currentDbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
|
||||||
console.log('invoice is in state', currentDbInvoice.actionState)
|
console.log('invoice is in state', currentDbInvoice.actionState)
|
||||||
|
|
||||||
if (['FAILED', 'PAID', 'RETRYING'].includes(currentDbInvoice.actionState)) {
|
if (PAID_ACTION_TERMINAL_STATES.includes(currentDbInvoice.actionState)) {
|
||||||
console.log('invoice is already in a terminal state, skipping transition')
|
console.log('invoice is already in a terminal state, skipping transition')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user