Compare commits

..

No commits in common. "3608d133d7fa01d6be034bca34d662dbc94717ed" and "ffc156df2bbc21eb8fcdc77bce4db17de8ee1984" have entirely different histories.

14 changed files with 65 additions and 332 deletions

View File

@ -13,7 +13,7 @@ import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from 'worker/wallet' import { finalizeHodlInvoice } from 'worker/wallet'
import walletDefs from 'wallets/server' import walletDefs from 'wallets/server'
import { generateResolverName, walletTypeToResolveType } from '@/lib/wallet' import { generateResolverName } from '@/lib/wallet'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
function injectResolvers (resolvers) { function injectResolvers (resolvers) {
@ -349,16 +349,10 @@ const resolvers = {
}) })
} }
}, },
Wallet: {
wallet: async (wallet) => {
return {
...wallet.wallet,
__resolveType: walletTypeToResolveType(wallet.type)
}
}
},
WalletDetails: { WalletDetails: {
__resolveType: wallet => wallet.__resolveType __resolveType (wallet) {
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : wallet.rune ? 'WalletCLN' : 'WalletLNbits'
}
}, },
Mutation: { Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => {

View File

@ -1,59 +1,32 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { fieldToGqlArg, generateResolverName, generateTypeDefName } from '@/lib/wallet' import { generateResolverName } from '@/lib/wallet'
import walletDefs from 'wallets/server' import walletDefs from 'wallets/server'
import { isServerField } from 'wallets' import { isServerField } from 'wallets'
function injectTypeDefs (typeDefs) { function injectTypeDefs (typeDefs) {
const injected = [rawTypeDefs(), mutationTypeDefs()]
return `${typeDefs}\n\n${injected.join('\n\n')}\n`
}
function mutationTypeDefs () {
console.group('injected GraphQL mutations:')
const typeDefs = walletDefs.map((w) => {
let args = 'id: ID, '
args += w.fields
.filter(isServerField)
.map(fieldToGqlArg).join(', ')
args += ', settings: AutowithdrawSettings!'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Boolean`
console.log(typeDef)
return typeDef
})
console.groupEnd()
return `extend type Mutation {\n${typeDefs.join('\n')}\n}`
}
function rawTypeDefs () {
console.group('injected GraphQL type defs:') console.group('injected GraphQL type defs:')
const injected = walletDefs.map(
const typeDefs = walletDefs.map((w) => { (w) => {
const args = w.fields let args = 'id: ID, '
.filter(isServerField) args += w.fields
.map(fieldToGqlArg) .filter(isServerField)
.map(s => ' ' + s) .map(f => {
.join('\n') let arg = `${f.name}: String`
const typeDefName = generateTypeDefName(w.walletField) if (!f.optional) {
const typeDef = `type ${typeDefName} {\n${args}\n}` arg += '!'
console.log(typeDef) }
return typeDef return arg
}) }).join(', ')
args += ', settings: AutowithdrawSettings!'
let union = 'union WalletDetails = ' const resolverName = generateResolverName(w.walletField)
union += walletDefs.map((w) => { const typeDef = `${resolverName}(${args}): Boolean`
const typeDefName = generateTypeDefName(w.walletField) console.log(typeDef)
return typeDefName return typeDef
}).join(' | ') })
console.log(union)
console.groupEnd() console.groupEnd()
return typeDefs.join('\n\n') + union return `${typeDefs}\n\nextend type Mutation {\n${injected.join('\n')}\n}`
} }
const typeDefs = ` const typeDefs = `
@ -88,6 +61,29 @@ const typeDefs = `
wallet: WalletDetails! wallet: WalletDetails!
} }
type WalletLNAddr {
address: String!
}
type WalletLND {
socket: String!
macaroon: String!
cert: String
}
type WalletCLN {
socket: String!
rune: String!
cert: String
}
type WalletLNbits {
url: String!
invoiceKey: String!
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN | WalletLNbits
input AutowithdrawSettings { input AutowithdrawSettings {
autoWithdrawThreshold: Int! autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float! autoWithdrawMaxFeePercent: Float!

View File

@ -120,4 +120,3 @@ OneOneSeven117,issue,#1272,#1268,easy,,,,10k,OneOneSeven@stacker.news,2024-07-31
aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31 aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31
Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-08-10 Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-08-10
aniskhalfallah,pr,#1289,,easy,,,,100k,aniskhalfallah@blink.sv,2024-08-12 aniskhalfallah,pr,#1289,,easy,,,,100k,aniskhalfallah@blink.sv,2024-08-12
riccardobl,pr,#1293,#1142,medium,high,,,500k,rblb@getalby.com,2024-08-18

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
120 aniskhalfallah pr #1264 #1226 good-first-issue 20k aniskhalfallah@stacker.news 2024-07-31
121 Gudnessuche issue #1264 #1226 good-first-issue 2k everythingsatoshi@getalby.com 2024-08-10
122 aniskhalfallah pr #1289 easy 100k aniskhalfallah@blink.sv 2024-08-12
riccardobl pr #1293 #1142 medium high 500k rblb@getalby.com 2024-08-18

View File

@ -10,4 +10,3 @@ mz
btcbagehot btcbagehot
felipe felipe
benalleng benalleng
rblb

View File

@ -107,7 +107,6 @@ mutation removeWallet($id: ID!) {
} }
` `
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET = gql` export const WALLET = gql`
query Wallet($id: ID!) { query Wallet($id: ID!) {
wallet(id: $id) { wallet(id: $id) {
@ -117,7 +116,7 @@ export const WALLET = gql`
type type
wallet { wallet {
__typename __typename
... on WalletLightningAddress { ... on WalletLNAddr {
address address
} }
... on WalletLND { ... on WalletLND {
@ -139,7 +138,6 @@ export const WALLET = gql`
} }
` `
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET_BY_TYPE = gql` export const WALLET_BY_TYPE = gql`
query WalletByType($type: String!) { query WalletByType($type: String!) {
walletByType(type: $type) { walletByType(type: $type) {
@ -150,7 +148,7 @@ export const WALLET_BY_TYPE = gql`
type type
wallet { wallet {
__typename __typename
... on WalletLightningAddress { ... on WalletLNAddr {
address address
} }
... on WalletLND { ... on WalletLND {

View File

@ -712,15 +712,6 @@ export const nwcSchema = object({
}) })
}) })
export const blinkSchema = object({
apiKey: string()
.required('required')
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }),
currency: string()
.transform(value => value ? value.toUpperCase() : 'BTC')
.oneOf(['USD', 'BTC'], 'must be BTC or USD')
})
export const lncSchema = object({ export const lncSchema = object({
pairingPhrase: array() pairingPhrase: array()
.transform(function (value, originalValue) { .transform(function (value, originalValue) {

View File

@ -1,22 +1,4 @@
export function fieldToGqlArg (field) {
let arg = `${field.name}: String`
if (!field.optional) {
arg += '!'
}
return arg
}
export function generateResolverName (walletField) { export function generateResolverName (walletField) {
const capitalized = walletField[0].toUpperCase() + walletField.slice(1) const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
return `upsert${capitalized}` return `upsert${capitalized}`
} }
export function generateTypeDefName (walletField) {
return walletField[0].toUpperCase() + walletField.slice(1)
}
export function walletTypeToResolveType (walletType) {
// wallet type is in UPPER_CASE but __resolveType requires PascalCase
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
return `Wallet${PascalCase}`
}

View File

@ -102,7 +102,7 @@ export default function WalletSettings () {
function WalletFields ({ wallet: { config, fields, isConfigured } }) { function WalletFields ({ wallet: { config, fields, isConfigured } }) {
return fields return fields
.map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { .map(({ name, label, type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => {
const rawProps = { const rawProps = {
...props, ...props,
name, name,
@ -115,7 +115,7 @@ function WalletFields ({ wallet: { config, fields, isConfigured } }) {
{label} {label}
{/* help can be a string or object to customize the label */} {/* help can be a string or object to customize the label */}
{help && ( {help && (
<Info label={help.label}> <Info label={help.label || 'help'}>
<Text>{help.text || help}</Text> <Text>{help.text || help}</Text>
</Info> </Info>
)} )}

View File

@ -1,188 +0,0 @@
import { galoyBlinkUrl } from 'wallets/blink'
export * from 'wallets/blink'
export async function testConnectClient ({ apiKey, currency }, { logger }) {
currency = currency ? currency.toUpperCase() : 'BTC'
logger.info('trying to fetch ' + currency + ' wallet')
await getWallet(apiKey, currency)
logger.ok(currency + ' wallet found')
}
export async function sendPayment (bolt11, { apiKey, currency }) {
const wallet = await getWallet(apiKey, currency)
const preImage = await payInvoice(apiKey, wallet, bolt11)
return { preImage }
}
async function payInvoice (authToken, wallet, invoice) {
const walletId = wallet.id
const out = await request(authToken, `
mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
lnInvoicePaymentSend(input: $input) {
status
errors {
message
path
code
}
transaction {
settlementVia {
... on SettlementViaIntraLedger {
preImage
}
... on SettlementViaLn {
preImage
}
}
}
}
}
`,
{
input: {
paymentRequest: invoice,
walletId
}
})
const status = out.data.lnInvoicePaymentSend.status
const errors = out.data.lnInvoicePaymentSend.errors
if (errors && errors.length > 0) {
throw new Error('failed to pay invoice ' + errors.map(e => e.code + ' ' + e.message).join(', '))
}
// payment was settled immediately
if (status === 'SUCCESS') {
const preimage = out.data.lnInvoicePaymentSend.transaction.settlementVia.preImage
if (!preimage) throw new Error('no preimage')
return preimage
}
// payment failed immediately
if (status === 'FAILED') {
throw new Error('failed to pay invoice')
}
// payment couldn't be settled (or fail) immediately, so we wait for a result
if (status === 'PENDING') {
while (true) {
// 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)
// settled
if (txInfo.status === 'SUCCESS') {
if (!txInfo.preImage) throw new Error('no preimage')
return txInfo.preImage
}
// failed
if (txInfo.status === 'FAILED') {
throw new Error(txInfo.error || 'failed to pay invoice')
}
// still pending
// retry later
}
}
// this should never happen
throw new Error('unexpected error')
}
async function getTxInfo (authToken, wallet, invoice) {
const walletId = wallet.id
let out
try {
out = await request(authToken, `
query GetTxInfo($walletId: WalletId!, $paymentRequest: LnPaymentRequest!) {
me {
defaultAccount {
walletById(walletId: $walletId) {
transactionsByPaymentRequest(paymentRequest: $paymentRequest) {
status
direction
settlementVia {
... on SettlementViaIntraLedger {
preImage
}
... on SettlementViaLn {
preImage
}
}
}
}
}
}
}
`,
{
paymentRequest: invoice,
walletId
})
} catch (e) {
// something went wrong during the query,
// maybe the connection was lost, so we just return
// a pending status, the caller can retry later
return {
status: 'PENDING',
preImage: null,
error: ''
}
}
const tx = out.data.me.defaultAccount.walletById.transactionsByPaymentRequest.find(t => t.direction === 'SEND')
if (!tx) {
// the transaction was not found, something went wrong
return {
status: 'FAILED',
preImage: null,
error: 'transaction not found'
}
}
const status = tx.status
const preImage = tx.settlementVia.preImage
return {
status,
preImage,
error: ''
}
}
async function getWallet (authToken, currency) {
const out = await request(authToken, `
query me {
me {
defaultAccount {
wallets {
id
walletCurrency
}
}
}
}
`, {})
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`)
}
async function request (authToken, query, variables = {}) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': authToken
},
body: JSON.stringify({ query, variables })
}
const res = await fetch(galoyBlinkUrl, options)
if (res.status >= 400 && res.status <= 599) {
if (res.status === 401) {
throw new Error('unauthorized')
} else {
throw new Error('API responded with HTTP ' + res.status)
}
}
return res.json()
}

View File

@ -1,34 +0,0 @@
import { blinkSchema } from '@/lib/validate'
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
export const name = 'blink'
export const fields = [
{
name: 'apiKey',
label: 'api key',
type: 'password',
help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl})`,
placeholder: 'blink_...'
},
{
name: 'currency',
label: 'wallet type',
type: 'text',
help: 'the blink wallet to use (BTC or USD for stablesats)',
placeholder: 'BTC',
optional: true,
clear: true,
autoComplete: 'off'
}
]
export const card = {
title: 'Blink',
subtitle: 'use [Blink](https://blink.sv/) for payments',
badges: ['send only']
}
export const fieldValidation = blinkSchema

View File

@ -5,6 +5,5 @@ import * as lnAddr from 'wallets/lightning-address/client'
import * as cln from 'wallets/cln/client' import * as cln from 'wallets/cln/client'
import * as lnd from 'wallets/lnd/client' import * as lnd from 'wallets/lnd/client'
import * as webln from 'wallets/webln/client' import * as webln from 'wallets/webln/client'
import * as blink from 'wallets/blink/client'
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink] export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln]

View File

@ -187,11 +187,11 @@ function useConfig (wallet) {
// Not optimal UX but the trade-off is saving invalid configurations // Not optimal UX but the trade-off is saving invalid configurations
// and maybe it's not that big of an issue. // and maybe it's not that big of an issue.
if (hasClientConfig) { if (hasClientConfig) {
let newClientConfig = extractClientConfig(wallet.fields, newConfig) const newClientConfig = extractClientConfig(wallet.fields, newConfig)
let valid = true let valid = true
try { try {
newClientConfig = await walletValidate(wallet, newClientConfig) await walletValidate(wallet, newClientConfig)
} catch { } catch {
valid = false valid = false
} }
@ -204,11 +204,11 @@ function useConfig (wallet) {
} }
} }
if (hasServerConfig) { if (hasServerConfig) {
let newServerConfig = extractServerConfig(wallet.fields, newConfig) const newServerConfig = extractServerConfig(wallet.fields, newConfig)
let valid = true let valid = true
try { try {
newServerConfig = await walletValidate(wallet, newServerConfig) await walletValidate(wallet, newServerConfig)
} catch { } catch {
valid = false valid = false
} }

View File

@ -1,5 +1,3 @@
import { msatsToSats } from '@/lib/format'
export * from 'wallets/lnbits' export * from 'wallets/lnbits'
export async function testConnectServer ({ url, invoiceKey }) { export async function testConnectServer ({ url, invoiceKey }) {
@ -16,12 +14,9 @@ export async function createInvoice (
headers.append('Content-Type', 'application/json') headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', invoiceKey) headers.append('X-Api-Key', invoiceKey)
// lnbits doesn't support msats so we have to floor to nearest sat
const sats = msatsToSats(msats)
const body = JSON.stringify({ const body = JSON.stringify({
amount: sats, amount: msats,
unit: 'sat', unit: 'msat',
expiry, expiry,
memo: description, memo: description,
out: false out: false

View File

@ -27,11 +27,13 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
SELECT EXISTS( SELECT EXISTS(
SELECT * SELECT *
FROM "Withdrawl" FROM "Withdrawl"
WHERE "userId" = ${id} WHERE "userId" = ${id} AND "autoWithdraw"
AND "autoWithdraw" AND (status IS NULL
AND status IS DISTINCT FROM 'CONFIRMED' OR (
AND now() < created_at + interval '1 hour' status <> 'CONFIRMED' AND
AND "msatsFeePaying" >= ${maxFeeMsats} now() < created_at + interval '1 hour' AND
"msatsFeePaying" >= ${maxFeeMsats}
))
)` )`
if (pendingOrFailed.exists) return if (pendingOrFailed.exists) return