blink receive

This commit is contained in:
Riccardo Balbo 2024-10-16 19:46:33 +02:00
parent ec8e775ae6
commit 14b6d7f818
9 changed files with 245 additions and 60 deletions

View File

@ -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
}
}
}
}

View File

@ -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()

View File

@ -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();

View File

@ -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

View File

@ -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()
}

62
wallets/blink/common.js Normal file
View File

@ -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 || []
}

View File

@ -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

62
wallets/blink/server.js Normal file
View File

@ -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
}

View File

@ -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