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>
This commit is contained in:
Riccardo Balbo 2024-08-18 18:36:55 +02:00 committed by GitHub
parent 3d8ae4a7a3
commit 2d139bed85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 238 additions and 5 deletions

View File

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

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) {

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
}