diff --git a/contributors.txt b/contributors.txt index 051b0224..e399c5e2 100644 --- a/contributors.txt +++ b/contributors.txt @@ -10,3 +10,4 @@ mz btcbagehot felipe benalleng +rblb \ No newline at end of file diff --git a/lib/validate.js b/lib/validate.js index cac68794..39ba6e63 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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) { diff --git a/wallets/blink/client.js b/wallets/blink/client.js new file mode 100644 index 00000000..f46c1d42 --- /dev/null +++ b/wallets/blink/client.js @@ -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() +} diff --git a/wallets/blink/index.js b/wallets/blink/index.js new file mode 100644 index 00000000..6cbc3ff8 --- /dev/null +++ b/wallets/blink/index.js @@ -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 diff --git a/wallets/client.js b/wallets/client.js index 891e54d9..b0d69e20 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -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] diff --git a/wallets/index.js b/wallets/index.js index 9681cc89..e7e8015e 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -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 }