Compare commits

...

7 Commits

Author SHA1 Message Date
k00b 3608d133d7 remove redundant info help text from wallets 2024-08-18 15:20:31 -05:00
k00b 506bb364d1 fix walletTypeToResolveType 2024-08-18 14:57:50 -05:00
k00b 9932a782b2 improve autowithdraw recent failure check 2024-08-18 14:06:17 -05:00
Keyan 11ddbe3983
Update awards.csv 2024-08-18 11:45:39 -05:00
Riccardo Balbo 2d139bed85
Blink wallet sending attachment (#1293)
* blink attachment

* support staging

* add staging dashboard link

* Revert "add staging dashboard link"

This reverts commit a43fa2204f03d74e733063aedd6862c6d71e4a46.

* Revert "support staging"

This reverts commit 93c15aa5083e60b1dafc77c30e999fb90fef8589.

* handle pending payments, code cleanup and comments

* stable sats -> stablesats

* catch HTTP errors

* print wallet currency in debug

* disable autocomplete

* schema without test()

* Fix save since default is not applied for empty strings

Formik validation must see 'currency' as undefined and apply the default but the validation before save sees an empty string.

* Save transformed config

* Remove unnecessary defaults

* Prefix HTTP error with text

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-08-18 11:36:55 -05:00
ekzyis 3d8ae4a7a3
Generate more server wallet code (#1309)
* Generate more code from wallet defs

* generate "type WalletLND { ... }"
* generate "union WalletDetails = WalletLND | ..."
* hardcode function for __resolveType
* add comments where updates are needed if another server wallet is added

* Fix type for LN addresses

* Generate __resolveType from wallet.type column
2024-08-18 11:32:25 -05:00
k00b d2d04ce141 lnbits doesn't support msats either 2024-08-17 18:43:39 -05:00
14 changed files with 333 additions and 66 deletions

View File

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

View File

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

View File

@ -120,3 +120,4 @@ 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
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
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
123 riccardobl pr #1293 #1142 medium high 500k rblb@getalby.com 2024-08-18

View File

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

View File

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

View File

@ -712,6 +712,15 @@ 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({
pairingPhrase: array()
.transform(function (value, originalValue) {

View File

@ -1,4 +1,22 @@
export function fieldToGqlArg (field) {
let arg = `${field.name}: String`
if (!field.optional) {
arg += '!'
}
return arg
}
export function generateResolverName (walletField) {
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
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 } }) {
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 = {
...props,
name,
@ -115,7 +115,7 @@ function WalletFields ({ wallet: { config, fields, isConfigured } }) {
{label}
{/* help can be a string or object to customize the label */}
{help && (
<Info label={help.label || 'help'}>
<Info label={help.label}>
<Text>{help.text || help}</Text>
</Info>
)}

188
wallets/blink/client.js Normal file
View File

@ -0,0 +1,188 @@
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()
}

34
wallets/blink/index.js Normal file
View File

@ -0,0 +1,34 @@
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,5 +5,6 @@ import * as lnAddr from 'wallets/lightning-address/client'
import * as cln from 'wallets/cln/client'
import * as lnd from 'wallets/lnd/client'
import * as webln from 'wallets/webln/client'
import * as blink from 'wallets/blink/client'
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln]
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink]

View File

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

View File

@ -1,3 +1,5 @@
import { msatsToSats } from '@/lib/format'
export * from 'wallets/lnbits'
export async function testConnectServer ({ url, invoiceKey }) {
@ -14,9 +16,12 @@ export async function createInvoice (
headers.append('Content-Type', 'application/json')
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({
amount: msats,
unit: 'msat',
amount: sats,
unit: 'sat',
expiry,
memo: description,
out: false

View File

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