From 2d0c49a51785b493bbfa4ff359ee56b0de45ac65 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Thu, 19 Dec 2024 16:24:53 +0100 Subject: [PATCH] zbd --- fragments/wallet.js | 3 + .../migration.sql | 24 +++++++ prisma/schema.prisma | 11 ++++ public/wallets/zbd-dark.svg | 1 + public/wallets/zbd.svg | 1 + wallets/client.js | 3 +- wallets/server.js | 3 +- wallets/zebedee/client.js | 63 +++++++++++++++++++ wallets/zebedee/index.js | 42 +++++++++++++ wallets/zebedee/server.js | 58 +++++++++++++++++ 10 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20241219120508_zebedee_attachment/migration.sql create mode 100644 public/wallets/zbd-dark.svg create mode 100644 public/wallets/zbd.svg create mode 100644 wallets/zebedee/client.js create mode 100644 wallets/zebedee/index.js create mode 100644 wallets/zebedee/server.js diff --git a/fragments/wallet.js b/fragments/wallet.js index 6f84f4afd..10e8ac060 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -169,6 +169,9 @@ export const WALLET_FIELDS = gql` apiKeyRecv currencyRecv } + ... on WalletZebedee { + gamerTagId + } } } ` diff --git a/prisma/migrations/20241219120508_zebedee_attachment/migration.sql b/prisma/migrations/20241219120508_zebedee_attachment/migration.sql new file mode 100644 index 000000000..416595e3e --- /dev/null +++ b/prisma/migrations/20241219120508_zebedee_attachment/migration.sql @@ -0,0 +1,24 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'ZEBEDEE'; + +-- CreateTable +CREATE TABLE "WalletZebedee" ( + "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, + "gamerTagId" TEXT, + + CONSTRAINT "WalletZebedee_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletZebedee_walletId_key" ON "WalletZebedee"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletZebedee" ADD CONSTRAINT "WalletZebedee_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Update wallet json +CREATE TRIGGER wallet_zebedee_as_jsonb +AFTER INSERT OR UPDATE ON "WalletZebedee" +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 59685b931..0db4ab4bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,6 +189,7 @@ enum WalletType { BLINK LNC WEBLN + ZEBEDEE } model Wallet { @@ -216,6 +217,7 @@ model Wallet { walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? walletBlink WalletBlink? + walletZebedee WalletZebedee? vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] @@ -325,6 +327,15 @@ model WalletPhoenixd { secondaryPassword String? } +model WalletZebedee { + 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") + gamerTagId String? +} + model Mute { muterId Int mutedId Int diff --git a/public/wallets/zbd-dark.svg b/public/wallets/zbd-dark.svg new file mode 100644 index 000000000..d2b03bd9a --- /dev/null +++ b/public/wallets/zbd-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/zbd.svg b/public/wallets/zbd.svg new file mode 100644 index 000000000..b92c3ff5c --- /dev/null +++ b/public/wallets/zbd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/client.js b/wallets/client.js index 8bd44698f..e5e5c3287 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -7,5 +7,6 @@ import * as lnd from '@/wallets/lnd/client' import * as webln from '@/wallets/webln/client' import * as blink from '@/wallets/blink/client' import * as phoenixd from '@/wallets/phoenixd/client' +import * as zebedee from '@/wallets/zebedee/client' -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] +export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd, zebedee] diff --git a/wallets/server.js b/wallets/server.js index f14e9fb36..0a39bc96e 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -6,6 +6,7 @@ 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 * as zebedee from '@/wallets/zebedee/server' // we import only the metadata of client side wallets import * as lnc from '@/wallets/lnc' @@ -20,7 +21,7 @@ import { timeoutSignal, withTimeout } from '@/lib/time' import { canReceive } from './common' import wrapInvoice from './wrap' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] +export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln, zebedee] const MAX_PENDING_INVOICES_PER_WALLET = 25 diff --git a/wallets/zebedee/client.js b/wallets/zebedee/client.js new file mode 100644 index 000000000..900a287c6 --- /dev/null +++ b/wallets/zebedee/client.js @@ -0,0 +1,63 @@ +import { API_URL, PREIMAGE_AWAIT_TIMEOUT_MS } from '@/wallets/zebedee' +import { assertContentTypeJson } from '@/lib/url' +import { callWithTimeout } from '@/lib/time' +import { fetchWithTimeout } from '@/lib/fetch' + +export * from '@/wallets/zebedee' + +export async function testSendPayment ({ apiKey }, { signal }) { + const wallet = await apiCall('wallet', { apiKey, method: 'GET' }, { signal }) + if (!wallet.data) throw new Error('wallet not found') +} + +export async function sendPayment (bolt11, { apiKey }, { signal }) { + const res = await apiCall('payments', { body: { invoice: bolt11 }, apiKey }, { signal }) + const { id, preimage } = res?.data + if (preimage) return preimage + // the api might return before the invoice is paid, so we'll wait for the preimage + return await waitForPreimage(id, { apiKey }, { signal }) +} + +async function waitForPreimage (id, { apiKey }, { signal }) { + return await callWithTimeout(async () => { + let preimage + while (true) { + const res = await apiCall('payments/{id}', { body: { id }, apiKey, method: 'GET' }, { signal }) + preimage = res?.data?.preimage + if (preimage) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + return preimage + }, PREIMAGE_AWAIT_TIMEOUT_MS) +} + +export async function apiCall (api, { body, apiKey, method = 'POST' }, { signal }) { + const headers = { + apikey: apiKey, + 'Content-Type': 'application/json' + } + if (method === 'GET' && body) { + for (const [k, v] of Object.entries(body)) { + api = api.replace('{' + k + '}', v) + } + } + const res = await fetchWithTimeout(API_URL + api, { + method, + headers, + signal, + body: method === 'POST' ? JSON.stringify(body) : undefined + }) + // https://zbd.dev/api-reference/errors + if (res.status !== 200) { + let error + try { + assertContentTypeJson(res) + const json = await res.json() + if (json?.message) error = json.message + } catch (e) { + error = res.statusText || 'error ' + res.status + } + throw new Error(error) + } + return res.json() +} diff --git a/wallets/zebedee/index.js b/wallets/zebedee/index.js new file mode 100644 index 000000000..a88af1225 --- /dev/null +++ b/wallets/zebedee/index.js @@ -0,0 +1,42 @@ +import { string } from '@/lib/yup' + +export const PREIMAGE_AWAIT_TIMEOUT_MS = 1_200 +export const STATIC_CHARGE_URL = 'https://api.zebedee.io/v0/process-static-charges/' +export const DASHBOARD_URL = 'https://dashboard.zebedee.io/' +export const GAMER_TAG_LNADDR_BASEURL = 'https://zbd.gg/.well-known/lnurlp/' +export const API_URL = 'https://api.zebedee.io/v0/' +export const ZEBEDEE_LNDOMAIN = 'zbd.gg' + +export const name = 'zebedee' +export const walletType = 'ZEBEDEE' +export const walletField = 'walletZebedee' + +export const fields = [ + { + name: 'apiKey', + label: 'api key', + type: 'password', + optional: 'for sending', + help: `you can get an API key from [Zebedee Dashboard](${DASHBOARD_URL}) from \n\`Project->API->Live\``, + clientOnly: true, + requiredWithout: 'gamerTagId', + validate: string() + }, + { + name: 'gamerTagId', + label: 'gamer tag or id', + type: 'text', + optional: 'for receiving', + help: `you can find your Gamertag in the [Zebedee Dashboard](${DASHBOARD_URL}) under \n\`Account->Gamertag\`\n section, or in the Zebedee app on the Wallet card.\nNote: You can also use your \`@${ZEBEDEE_LNDOMAIN}\` Lightning address here.`, + serverOnly: true, + requiredWithout: 'apiKey', + validate: string() + } +] + +export const card = { + title: 'Zebedee', + subtitle: 'use [Zebedee](https://zebedee.io) for payments', + image: { src: '/wallets/zbd.svg' } + +} diff --git a/wallets/zebedee/server.js b/wallets/zebedee/server.js new file mode 100644 index 000000000..810b0576a --- /dev/null +++ b/wallets/zebedee/server.js @@ -0,0 +1,58 @@ +import { GAMER_TAG_LNADDR_BASEURL, STATIC_CHARGE_URL, ZEBEDEE_LNDOMAIN } from '@/wallets/zebedee' +import { fetchWithTimeout } from '@/lib/fetch' +import { assertContentTypeJson } from '@/lib/url' + +export * from '@/wallets/zebedee' + +async function fetchJson (url, { signal }) { + let res = await fetchWithTimeout(url, { signal }) + assertContentTypeJson(res) + if (!res.ok) { + res.text().catch(() => {}) + throw new Error(res.statusText || 'error ' + res.status) + } + res = await res.json() + if (res.status?.toLowerCase() === 'error') { + throw new Error(res.reason) + } + return res +} + +function isGamerTag (value) { + if (value.endsWith('@' + ZEBEDEE_LNDOMAIN)) return true + return value.length > 0 && value.length < 30 +} + +export async function fetchGamerId (value, { signal }) { + if (isGamerTag(value)) { + const [gamerTag, domain] = value.split('@') + if (domain && domain !== ZEBEDEE_LNDOMAIN) throw new Error(`invalid gamer tag: not a @${ZEBEDEE_LNDOMAIN} lightning address`) + const url = GAMER_TAG_LNADDR_BASEURL + gamerTag + try { + const res = await fetchJson(url, { signal }) + const callback = res.callback + if (!callback) throw new Error('cannot fetch gamer id: ' + (res.statusText || 'error ' + res.status)) + const gamerId = callback.substring(callback.lastIndexOf('/') + 1) + return gamerId + } catch (e) { + throw new Error('cannot fetch gamer id: ' + e.message) + } + } + return value +} + +export async function testCreateInvoice (credentials, { signal }) { + credentials.gamerTagId = await fetchGamerId(credentials.gamerTagId, { signal }) + return await createInvoice({ msats: 1000, expiry: 1 }, credentials, { signal }) +} + +export async function createInvoice ({ msats, description, expiry }, { gamerTagId }, { signal }) { + try { + const url = STATIC_CHARGE_URL + gamerTagId + '?amount=' + msats + '&comment=' + description + const res = await fetchJson(url, { signal }) + if (!res.pr) throw new Error('cannot fetch invoice') + return res.pr + } catch (e) { + throw new Error(e.message) + } +}