commit
						fee9a96186
					
				@ -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
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -751,14 +751,33 @@ export const nwcSchema = object().shape({
 | 
			
		||||
  ...autowithdrawSchemaMembers
 | 
			
		||||
}, ['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 api key for receiving not set')
 | 
			
		||||
      return schema.test({
 | 
			
		||||
        test: apiKey => apiKey !== apiKeyRecv,
 | 
			
		||||
        message: 'api key for sending cannot be the same as for receiving'
 | 
			
		||||
      })
 | 
			
		||||
    }),
 | 
			
		||||
  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 api key for sending not set')
 | 
			
		||||
      return schema.test({
 | 
			
		||||
        test: apiKeyRecv => apiKeyRecv !== apiKey,
 | 
			
		||||
        message: 'api key for receiving cannot be the same as for sending'
 | 
			
		||||
      })
 | 
			
		||||
    }),
 | 
			
		||||
  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'),
 | 
			
		||||
  ...autowithdrawSchemaMembers
 | 
			
		||||
}, ['apiKey', 'apiKeyRecv'])
 | 
			
		||||
 | 
			
		||||
export const lncSchema = object({
 | 
			
		||||
  pairingPhrase: string()
 | 
			
		||||
 | 
			
		||||
@ -110,7 +110,7 @@ function WalletFields ({ wallet: { config, fields, isConfigured } }) {
 | 
			
		||||
        ...props,
 | 
			
		||||
        name,
 | 
			
		||||
        initialValue: config?.[name],
 | 
			
		||||
        readOnly: isClient && isConfigured && editable === false && !!config?.[name],
 | 
			
		||||
        readOnly: isClient && editable === false && ((isConfigured && !!config?.[name]) || !!props.value),
 | 
			
		||||
        groupClassName: props.hidden ? 'd-none' : undefined,
 | 
			
		||||
        label: label
 | 
			
		||||
          ? (
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										26
									
								
								prisma/migrations/20241016171557_blinkreceive/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								prisma/migrations/20241016171557_blinkreceive/migration.sql
									
									
									
									
									
										Normal 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();
 | 
			
		||||
@ -179,6 +179,7 @@ enum WalletType {
 | 
			
		||||
  LNBITS
 | 
			
		||||
  NWC
 | 
			
		||||
  PHOENIXD
 | 
			
		||||
  BLINK
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Wallet {
 | 
			
		||||
@ -205,6 +206,7 @@ model Wallet {
 | 
			
		||||
  walletLNbits           WalletLNbits?
 | 
			
		||||
  walletNWC              WalletNWC?
 | 
			
		||||
  walletPhoenixd         WalletPhoenixd?
 | 
			
		||||
  walletBlink            WalletBlink?
 | 
			
		||||
  withdrawals            Withdrawl[]
 | 
			
		||||
  InvoiceForward         InvoiceForward[]
 | 
			
		||||
 | 
			
		||||
@ -273,6 +275,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
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,19 @@
 | 
			
		||||
import { galoyBlinkUrl } from 'wallets/blink'
 | 
			
		||||
import { getScopes, SCOPE_READ, 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 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')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  currency = currency ? currency.toUpperCase() : 'BTC'
 | 
			
		||||
  await getWallet(apiKey, currency)
 | 
			
		||||
 | 
			
		||||
  logger.ok(currency + ' wallet found')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -143,45 +152,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
									
								
							
							
						
						
									
										62
									
								
								wallets/blink/common.js
									
									
									
									
									
										Normal 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 || []
 | 
			
		||||
}
 | 
			
		||||
@ -1,34 +1,61 @@
 | 
			
		||||
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,
 | 
			
		||||
    editable: false
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'currency',
 | 
			
		||||
    label: 'wallet type',
 | 
			
		||||
    type: 'text',
 | 
			
		||||
    help: 'the blink wallet to use (BTC or USD for stablesats)',
 | 
			
		||||
    help: 'the blink wallet to use for sending (BTC or USD for stablesats)',
 | 
			
		||||
    placeholder: 'BTC',
 | 
			
		||||
    optional: true,
 | 
			
		||||
    clear: true,
 | 
			
		||||
    autoComplete: 'off'
 | 
			
		||||
    autoComplete: 'off',
 | 
			
		||||
    optional: 'for sending',
 | 
			
		||||
    clientOnly: true,
 | 
			
		||||
    editable: false
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    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,
 | 
			
		||||
    editable: false
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'currencyRecv',
 | 
			
		||||
    label: 'wallet type',
 | 
			
		||||
    type: 'text',
 | 
			
		||||
    help: 'the blink wallet to use for receiving (only BTC available)',
 | 
			
		||||
    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']
 | 
			
		||||
  badges: ['send & receive']
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const fieldValidation = blinkSchema
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										61
									
								
								wallets/blink/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								wallets/blink/server.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,61 @@
 | 
			
		||||
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 scopes = await getScopes(apiKeyRecv)
 | 
			
		||||
  if (!scopes.includes(SCOPE_READ)) {
 | 
			
		||||
    throw new Error('missing READ scope')
 | 
			
		||||
  }
 | 
			
		||||
  if (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 create invoice ' + errors.map(e => e.code + ' ' + e.message).join(', '))
 | 
			
		||||
  }
 | 
			
		||||
  const invoice = res.invoice.paymentRequest
 | 
			
		||||
  return invoice
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user