diff --git a/fragments/wallet.js b/fragments/wallet.js index 89feb7cd..49e4d3f3 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -141,6 +141,10 @@ export const WALLET = gql` url secondaryPassword } + ... on WalletBlink { + apiKeyRecv + currencyRecv + } } } } @@ -181,6 +185,10 @@ export const WALLET_BY_TYPE = gql` url secondaryPassword } + ... on WalletBlink { + apiKeyRecv + currencyRecv + } } } } diff --git a/lib/validate.js b/lib/validate.js index 00215d1a..570ec796 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -748,14 +748,32 @@ export const nwcSchema = object().shape({ }) }, ['nwcUrl', 'nwcUrlRecv']) -export const blinkSchema = object({ +export const blinkSchema = object().shape({ apiKey: string() - .required('required') - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), + .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }) + .when(['apiKeyRecv'], ([apiKeyRecv], schema) => { + if (!apiKeyRecv) return schema.required('required if apiKeyRecv not set') + return schema.test({ + test: apiKey => apiKey !== apiKeyRecv, + message: 'apiKey cannot be the same as apiKeyRecv' + }) + }), + apiKeyRecv: string() + .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }) + .when(['apiKey'], ([apiKey], schema) => { + if (!apiKey) return schema.required('required if apiKey not set') + return schema.test({ + test: apiKeyRecv => apiKeyRecv !== apiKey, + message: 'apiKeyRecv cannot be the same as apiKey' + }) + }), currency: string() .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['USD', 'BTC'], 'must be BTC or USD') -}) + .oneOf(['USD', 'BTC'], 'must be BTC or USD'), + currencyRecv: string() + .transform(value => value ? value.toUpperCase() : 'BTC') + .oneOf(['BTC'], 'must be BTC') +}, ['apiKey', 'apiKeyRecv']) export const lncSchema = object({ pairingPhrase: string() diff --git a/prisma/migrations/20241016171557_blinkreceive/migration.sql b/prisma/migrations/20241016171557_blinkreceive/migration.sql new file mode 100644 index 00000000..04f940c6 --- /dev/null +++ b/prisma/migrations/20241016171557_blinkreceive/migration.sql @@ -0,0 +1,26 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'BLINK'; + +-- CreateTable +CREATE TABLE "WalletBlink" ( + "id" 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, + "apiKeyRecv" TEXT NOT NULL, + "currencyRecv" TEXT, + + CONSTRAINT "WalletBlink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_walletId_key" ON "WalletBlink"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Update wallet json +CREATE TRIGGER wallet_blink_as_jsonb +AFTER INSERT OR UPDATE ON "WalletBlink" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a629e44..9bc1088e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,6 +178,7 @@ enum WalletType { LNBITS NWC PHOENIXD + BLINK } model Wallet { @@ -204,6 +205,7 @@ model Wallet { walletLNbits WalletLNbits? walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? + walletBlink WalletBlink? withdrawals Withdrawl[] InvoiceForward InvoiceForward[] @@ -272,6 +274,16 @@ model WalletNWC { nwcUrlRecv String } +model WalletBlink { + id 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") + apiKeyRecv String + currencyRecv String? +} + model WalletPhoenixd { id Int @id @default(autoincrement()) walletId Int @unique diff --git a/wallets/blink/client.js b/wallets/blink/client.js index d3aa2751..c24c186f 100644 --- a/wallets/blink/client.js +++ b/wallets/blink/client.js @@ -1,10 +1,23 @@ -import { galoyBlinkUrl } from 'wallets/blink' +import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' export * from 'wallets/blink' export async function testSendPayment ({ apiKey, currency }, { logger }) { - currency = currency ? currency.toUpperCase() : 'BTC' logger.info('trying to fetch ' + currency + ' wallet') + const strict = false + const scopes = await getScopes(apiKey) + if (!scopes.includes(SCOPE_READ)) { + throw new Error('missing READ scope') + } + if (!scopes.includes(SCOPE_WRITE)) { + throw new Error('missing WRITE scope') + } + if (strict && scopes.includes(SCOPE_RECEIVE)) { + throw new Error('RECEIVE scope must not be present') + } + + currency = currency ? currency.toUpperCase() : 'BTC' await getWallet(apiKey, currency) + logger.ok(currency + ' wallet found') } @@ -143,45 +156,3 @@ async function getTxInfo (authToken, wallet, invoice) { 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/common.js b/wallets/blink/common.js new file mode 100644 index 00000000..acb11aec --- /dev/null +++ b/wallets/blink/common.js @@ -0,0 +1,62 @@ +export const galoyBlinkUrl = 'https://api.blink.sv/graphql' +export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' + +export const SCOPE_READ = 'READ' +export const SCOPE_WRITE = 'WRITE' +export const SCOPE_RECEIVE = 'RECEIVE' + +export 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`) +} + +export 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) { + // consume res + res.text().catch(() => {}) + if (res.status === 401) { + throw new Error('unauthorized') + } else { + throw new Error('API responded with HTTP ' + res.status) + } + } + return res.json() +} + +export async function getScopes (authToken) { + const out = await request(authToken, ` + query scopes { + authorization { + scopes + } + } + `, {}) + const scopes = out?.data?.authorization?.scopes + return scopes || [] +} diff --git a/wallets/blink/index.js b/wallets/blink/index.js index 6cbc3ff8..b8e45e35 100644 --- a/wallets/blink/index.js +++ b/wallets/blink/index.js @@ -1,17 +1,20 @@ import { blinkSchema } from '@/lib/validate' - -export const galoyBlinkUrl = 'https://api.blink.sv/graphql' -export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' +import { galoyBlinkDashboardUrl } from 'wallets/blink/common' export const name = 'blink' +export const walletType = 'BLINK' +export const walletField = 'walletBlink' +export const fieldValidation = blinkSchema export const fields = [ { name: 'apiKey', label: 'api key', type: 'password', - help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl})`, - placeholder: 'blink_...' + help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Write' scopes when generating this API key.`, + placeholder: 'blink_...', + optional: 'for sending', + clientOnly: true }, { name: 'currency', @@ -19,16 +22,38 @@ export const fields = [ type: 'text', help: 'the blink wallet to use (BTC or USD for stablesats)', placeholder: 'BTC', - optional: true, clear: true, - autoComplete: 'off' + autoComplete: 'off', + optional: 'for sending', + clientOnly: true + + }, + { + name: 'apiKeyRecv', + label: 'api key', + type: 'password', + help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Receive' scopes when generating this API key.`, + placeholder: 'blink_...', + optional: 'for receiving', + serverOnly: true + }, + { + name: 'currencyRecv', + label: 'wallet type', + type: 'text', + help: 'the blink wallet to use (BTC or USD for stablesats)', + value: 'BTC', + clear: true, + autoComplete: 'off', + optional: 'for receiving', + serverOnly: true, + editable: false + } ] export const card = { title: 'Blink', subtitle: 'use [Blink](https://blink.sv/) for payments', - badges: ['send only'] + badges: ['send & receive'] } - -export const fieldValidation = blinkSchema diff --git a/wallets/blink/server.js b/wallets/blink/server.js new file mode 100644 index 00000000..fbbb4401 --- /dev/null +++ b/wallets/blink/server.js @@ -0,0 +1,62 @@ +import { withTimeout } from '@/lib/time' +import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from 'wallets/blink/common' +import { msatsToSats } from '@/lib/format' +export * from 'wallets/blink' + +export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }) { + const strict = true + const scopes = await getScopes(apiKeyRecv) + if (!scopes.includes(SCOPE_READ)) { + throw new Error('missing READ scope') + } + if (strict && scopes.includes(SCOPE_WRITE)) { + throw new Error('WRITE scope must not be present') + } + if (!scopes.includes(SCOPE_RECEIVE)) { + throw new Error('missing RECEIVE scope') + } + + const timeout = 15_000 + currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' + return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }), timeout) +} + +export async function createInvoice ( + { msats, description, expiry }, + { apiKeyRecv, currencyRecv }) { + currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' + + const wallet = await getWallet(apiKeyRecv, currencyRecv) + + if (currencyRecv !== 'BTC') { + throw new Error('unsupported currency ' + currencyRecv) + } + const mutation = ` + mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { + lnInvoiceCreate(input: $input) { + invoice { + paymentRequest + } + errors { + message + } + } + } + ` + + const out = await request(apiKeyRecv, mutation, { + input: { + amount: msatsToSats(msats), + expiresIn: Math.floor(expiry / 60) || 1, + memo: description, + walletId: wallet.id + } + }) + const res = out.data.lnInvoiceCreate + const errors = res.errors + if (errors && errors.length > 0) { + throw new Error('failed to pay invoice ' + errors.map(e => e.code + ' ' + e.message).join(', ')) + } + const invoice = res.invoice.paymentRequest + return invoice +} diff --git a/wallets/server.js b/wallets/server.js index 8f8e8f35..7cdc1c10 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -4,13 +4,14 @@ import * as lnAddr from 'wallets/lightning-address/server' import * as lnbits from 'wallets/lnbits/server' import * as nwc from 'wallets/nwc/server' import * as phoenixd from 'wallets/phoenixd/server' +import * as blink from 'wallets/blink/server' import { addWalletLog } from '@/api/resolvers/wallet' import walletDefs from 'wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd] +export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink] const MAX_PENDING_INVOICES_PER_WALLET = 25