From 0046a71723eb4538f46a607da1640774e0047b0a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 11 Apr 2025 04:18:02 +0200 Subject: [PATCH 1/8] Migrate existing vault entries to new schema --- .../migration.sql | 178 ++++++++++++++++++ prisma/schema.prisma | 67 +++++-- 2 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20250411012334_vault_refactor/migration.sql diff --git a/prisma/migrations/20250411012334_vault_refactor/migration.sql b/prisma/migrations/20250411012334_vault_refactor/migration.sql new file mode 100644 index 000000000..cc2d72155 --- /dev/null +++ b/prisma/migrations/20250411012334_vault_refactor/migration.sql @@ -0,0 +1,178 @@ +/* + Warnings: + + - A unique constraint covering the columns `[apiKeyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[currencyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[adminKeyId]` on the table `WalletLNbits` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[nwcUrlId]` on the table `WalletNWC` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[primaryPasswordId]` on the table `WalletPhoenixd` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "WalletBlink" + ADD COLUMN "apiKeyId" INTEGER, + ADD COLUMN "currencyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletLNbits" ADD COLUMN "adminKeyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletNWC" ADD COLUMN "nwcUrlId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletPhoenixd" ADD COLUMN "primaryPasswordId" INTEGER; + +-- CreateTable +CREATE TABLE "Vault" ( + "id" SERIAL NOT NULL, + "iv" TEXT NOT NULL, + "value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Vault_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletLNC" ( + "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, + "pairingPhraseId" INTEGER, + "localKeyId" INTEGER, + "remoteKeyId" INTEGER, + "serverHostId" INTEGER, + + CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_apiKeyId_key" ON "WalletBlink"("apiKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_currencyId_key" ON "WalletBlink"("currencyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNbits_adminKeyId_key" ON "WalletLNbits"("adminKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletNWC_nwcUrlId_key" ON "WalletNWC"("nwcUrlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletPhoenixd_primaryPasswordId_key" ON "WalletPhoenixd"("primaryPasswordId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_pairingPhraseId_key" ON "WalletLNC"("pairingPhraseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_localKeyId_key" ON "WalletLNC"("localKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_remoteKeyId_key" ON "WalletLNC"("remoteKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_serverHostId_key" ON "WalletLNC"("serverHostId"); + +-- AddForeignKey +ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_adminKeyId_fkey" FOREIGN KEY ("adminKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletNWC" ADD CONSTRAINT "WalletNWC_nwcUrlId_fkey" FOREIGN KEY ("nwcUrlId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_currencyId_fkey" FOREIGN KEY ("currencyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletPhoenixd" ADD CONSTRAINT "WalletPhoenixd_primaryPasswordId_fkey" FOREIGN KEY ("primaryPasswordId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_pairingPhraseId_fkey" FOREIGN KEY ("pairingPhraseId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_localKeyId_fkey" FOREIGN KEY ("localKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_remoteKeyId_fkey" FOREIGN KEY ("remoteKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_serverHostId_fkey" FOREIGN KEY ("serverHostId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + + +CREATE OR REPLACE FUNCTION migrate_wallet_vault() +RETURNS void AS +$$ +DECLARE + vaultEntry "VaultEntry"%ROWTYPE; +BEGIN + FOR vaultEntry IN SELECT * FROM "VaultEntry" LOOP + DECLARE + vaultId INT; + walletType "WalletType"; + BEGIN + INSERT INTO "Vault" ("iv", "value") + VALUES (vaultEntry."iv", vaultEntry."value") + RETURNING id INTO vaultId; + + SELECT type INTO walletType + FROM "Wallet" + WHERE id = vaultEntry."walletId"; + + CASE walletType + WHEN 'LNBITS' THEN + UPDATE "WalletLNbits" + SET "adminKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'NWC' THEN + UPDATE "WalletNWC" + SET "nwcUrlId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'BLINK' THEN + IF vaultEntry."key" = 'apiKey' THEN + UPDATE "WalletBlink" + SET "apiKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSE + UPDATE "WalletBlink" + SET "currencyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + WHEN 'PHOENIXD' THEN + UPDATE "WalletPhoenixd" + SET "primaryPasswordId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'LNC' THEN + IF vaultEntry."key" = 'pairingPhrase' THEN + UPDATE "WalletLNC" + SET "pairingPhraseId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'localKey' THEN + UPDATE "WalletLNC" + SET "localKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'remoteKey' THEN + UPDATE "WalletLNC" + SET "remoteKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'serverHost' THEN + UPDATE "WalletLNC" + SET "serverHostId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + END CASE; + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +SELECT migrate_wallet_vault(); +DROP FUNCTION migrate_wallet_vault(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49c4b858e..760f5aae0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -240,6 +240,7 @@ model Wallet { walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? walletBlink WalletBlink? + walletLNC WalletLNC? vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] @@ -250,6 +251,24 @@ model Wallet { @@index([priority]) } +model Vault { + id Int @id @default(autoincrement()) + iv String @db.Text + value String @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + walletLNbits WalletLNbits? + walletNWC WalletNWC? + walletBlinkApiKey WalletBlink? @relation("blinkApiKeySend") + walletBlinkCurrency WalletBlink? @relation("blinkCurrencySend") + walletPhoenixd WalletPhoenixd? + walletLNCPairingPhrase WalletLNC? @relation("lncPairingPhrase") + walletLNCRemoteKey WalletLNC? @relation("lncRemoteKey") + walletLNCServerHost WalletLNC? @relation("lncServerHost") + walletLNCLocalKey WalletLNC? @relation("lncLocalKey") +} + model VaultEntry { id Int @id @default(autoincrement()) key String @db.Text @@ -322,25 +341,33 @@ model WalletLNbits { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") url String invoiceKey String? + adminKeyId Int? @unique + adminKey Vault? @relation(fields: [adminKeyId], references: [id]) } model WalletNWC { - 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") - 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? + nwcUrlRecv String? + nwcUrlId Int? @unique + nwcUrl Vault? @relation(fields: [nwcUrlId], references: [id]) +} + +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? + apiKeyId Int? @unique + apiKey Vault? @relation("blinkApiKeySend", fields: [apiKeyId], references: [id]) + currencyId Int? @unique + currency Vault? @relation("blinkCurrencySend", fields: [currencyId], references: [id]) } model WalletPhoenixd { @@ -351,6 +378,24 @@ model WalletPhoenixd { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") url String secondaryPassword String? + primaryPasswordId Int? @unique + primaryPassword Vault? @relation(fields: [primaryPasswordId], references: [id]) +} + +model WalletLNC { + 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") + pairingPhraseId Int? @unique + pairingPhrase Vault? @relation("lncPairingPhrase", fields: [pairingPhraseId], references: [id]) + localKeyId Int? @unique + localKey Vault? @relation("lncLocalKey", fields: [localKeyId], references: [id]) + remoteKeyId Int? @unique + remoteKey Vault? @relation("lncRemoteKey", fields: [remoteKeyId], references: [id]) + serverHostId Int? @unique + serverHost Vault? @relation("lncServerHost", fields: [serverHostId], references: [id]) } model Mute { From e19dcb21d259326ae5bd34ae46465de903569099 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 15 Apr 2025 00:34:54 +0200 Subject: [PATCH 2/8] Read+write new vault schema --- api/resolvers/vault.js | 40 +++++- api/resolvers/wallet.js | 97 +++++-------- lib/object.js | 25 ++++ lib/object.spec.js | 47 ++++++ .../migration.sql | 26 ++++ prisma/schema.prisma | 43 +++--- wallets/vault.js | 134 ++++++++++++++++++ worker/wallet.js | 7 +- 8 files changed, 329 insertions(+), 90 deletions(-) create mode 100644 lib/object.js create mode 100644 lib/object.spec.js create mode 100644 wallets/vault.js diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 1211adaf5..ab7b4374a 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -1,11 +1,24 @@ import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { getWalletByType } from '@/wallets/common' +import { deleteVault, hasVault, vaultNewSchematoTypedef, vaultPrismaFragments } from '@/wallets/vault' export default { Query: { getVaultEntries: async (parent, args, { me, models }) => { if (!me) throw new GqlAuthenticationError() - return await models.vaultEntry.findMany({ where: { userId: me.id } }) + const { include } = vaultPrismaFragments() + const wallets = await models.wallet.findMany({ + where: { userId: me.id }, + include + }) + + const vaultEntries = [] + for (const wallet of wallets) { + vaultEntries.push(...vaultNewSchematoTypedef(wallet).vaultEntries) + } + + return vaultEntries } }, Mutation: { @@ -29,12 +42,22 @@ export default { })) } - for (const entry of entries) { - txs.push(models.vaultEntry.update({ - where: { userId_key: { userId: me.id, key: entry.key } }, - data: { value: entry.value, iv: entry.iv } - })) + const wallets = await models.wallet.findMany({ where: { userId: me.id } }) + for (const wallet of wallets) { + const def = getWalletByType(wallet.type) + const vaultFrags = vaultPrismaFragments({ ...wallet, vaultEntries: entries }) + txs.push( + models.wallet.update({ + where: { id: wallet.id }, + data: { + [def.walletField]: { + update: vaultFrags.upsert + } + } + }) + ) } + await models.$transaction(txs) return true }, @@ -45,7 +68,10 @@ export default { where: { id: me.id }, data: { vaultKeyHash: '' } })) - txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } })) + + const wallets = await models.wallet.findMany({ where: { userId: me.id } }) + txs.push(...wallets.filter(hasVault).map(wallet => deleteVault(models, wallet))) + await models.$transaction(txs) return true } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 1417f8906..9f1b65346 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -29,6 +29,7 @@ import { canReceive, getWalletByType } from '@/wallets/common' import performPaidAction from '../paidAction' import performPayingAction from '../payingAction' import { timeoutSignal, withTimeout } from '@/lib/time' +import { deleteVault, hasVault, vaultNewSchematoTypedef, vaultPrismaFragments } from '@/wallets/vault' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -43,11 +44,14 @@ function injectResolvers (resolvers) { // this mutation was sent from an unsynced client // to pass validation, we need to add the existing vault entries for validation // in case the client is removing the receiving config - existingVaultEntries = await models.vaultEntry.findMany({ + // TODO: replace this usage because of new schema + const wallet = await models.wallet.findUnique({ where: { - walletId: Number(data.id) - } + id: Number(data.id) + }, + include: vaultPrismaFragments().include }) + existingVaultEntries = vaultNewSchematoTypedef(wallet).vaultEntries } const validData = await validateWallet(walletDef, @@ -159,10 +163,8 @@ const resolvers = { throw new GqlAuthenticationError() } - return await models.wallet.findMany({ - include: { - vaultEntries: true - }, + const wallets = await models.wallet.findMany({ + include: vaultPrismaFragments().include, where: { userId: me.id }, @@ -170,6 +172,8 @@ const resolvers = { priority: 'asc' } }) + + return wallets.map(vaultNewSchematoTypedef) }, withdrawl: getWithdrawl, direct: async (parent, { id }, { me, models }) => { @@ -569,7 +573,11 @@ const resolvers = { } const logger = walletLogger({ wallet, models }) - await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) + + await models.$transaction([ + hasVault(wallet) ? deleteVault(models, wallet) : null, + models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) + ].filter(Boolean)) if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) { logger.info('details for receiving deleted') @@ -838,15 +846,12 @@ async function upsertWallet ( const txs = [] - if (id) { - const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } }) + const vaultFrags = vaultPrismaFragments({ ...wallet, vaultEntries }) - // createMany is the set difference of the new - old - // deleteMany is the set difference of the old - new - // updateMany is the intersection of the old and new - const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key])) - const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key])) - .map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) })) + if (id) { + const dbWallet = await models.wallet.findUnique({ + where: { id: Number(id), userId: me.id } + }) txs.push( models.wallet.update({ @@ -854,62 +859,28 @@ async function upsertWallet ( data: { enabled, priority, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 - ? { - [wallet.field]: { - upsert: { - create: recvConfig, - update: recvConfig - } - } - } - : {}), - ...(vaultEntries - ? { - vaultEntries: { - deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({ - userId: me.id, key - })), - create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({ - key, iv, value, userId: me.id - })), - update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({ - where: { userId_key: { userId: me.id, key } }, - data: { value, iv } - })) - } - } - : {}) - + [wallet.field]: { + upsert: { + create: { ...recvConfig, ...vaultFrags?.create }, + update: { ...recvConfig, ...vaultFrags?.upsert } + }, + // XXX the check is required because the update would fail if there is no row to delete ... + update: hasVault(dbWallet) ? vaultFrags?.deleteMissing : undefined + } }, - include: { - vaultEntries: true - } + include: vaultFrags?.include }) ) } else { txs.push( models.wallet.create({ - include: { - vaultEntries: true - }, + include: vaultFrags?.include, data: { enabled, priority, userId: me.id, type: wallet.type, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}), - ...(vaultEntries - ? { - vaultEntries: { - createMany: { - data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id })) - } - } - } - : {}) + [wallet.field]: { create: { ...recvConfig, ...vaultFrags?.create } } } }) ) @@ -946,7 +917,9 @@ async function upsertWallet ( } const [upsertedWallet] = await models.$transaction(txs) - return upsertedWallet + + // migrate from old schema to new schema for vault + return vaultNewSchematoTypedef(upsertedWallet) } export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) { diff --git a/lib/object.js b/lib/object.js new file mode 100644 index 000000000..ada24f7f2 --- /dev/null +++ b/lib/object.js @@ -0,0 +1,25 @@ +export const get = (obj, path) => { + if (!path) return obj + const keys = path.split('.') + return keys.reduce((obj, key) => obj?.[key], obj) +} + +export const set = (obj, path, value) => { + const keys = path.split('.') + const lastKey = keys.pop() + const parent = get(obj, keys.join('.')) + parent[lastKey] = value +} + +export const remove = (obj, path) => { + const keys = path.split('.') + const lastKey = keys.pop() + const parent = get(obj, keys.join('.')) + delete parent?.[lastKey] +} + +export const move = (obj, fromPath, toPath) => { + const value = get(obj, fromPath) + remove(obj, fromPath) + set(obj, toPath, value) +} diff --git a/lib/object.spec.js b/lib/object.spec.js new file mode 100644 index 000000000..2b26cfe11 --- /dev/null +++ b/lib/object.spec.js @@ -0,0 +1,47 @@ +/* eslint-env jest */ + +import { get, move, remove, set } from './object' + +describe('object helpers', () => { + test.each([ + [{ a: 'b' }, '', { a: 'b' }], + [{ a: 'b' }, 'a', 'b'], + [{ a: { b: { c: 'd' } } }, 'a.b', { c: 'd' }] + ])( + 'gets a nested value: get(%p, %p) returns %p', + (obj, path, expected) => { + expect(get(obj, path)).toEqual(expected) + }) + + test.each([ + [{ a: 'b' }, '', { a: 'b' }], + [{ a: { b: { c: 'd' } } }, 'a.b.c', 'e', { a: { b: { c: 'e' } } }] + ])( + 'sets a nested value: set(%p, %p, %p) returns %p', + () => { + const obj = { a: { b: { c: 'd' } } } + set(obj, 'a.b.c', 'e') + expect(obj).toEqual({ a: { b: { c: 'e' } } }) + }) + + test.each([ + [{ a: 'b' }, 'a', {}], + [{ a: { b: { c: 'd' } } }, 'a.b.c', { a: { b: {} } }] + ])( + 'removes a nested values: remove(%p, %p) returns %p', + (obj, path, expected) => { + remove(obj, path) + expect(obj).toEqual(expected) + }) + + test.each([ + [{ a: { b1: { c: 'd' } } }, 'a.b1.c', 'a.b1.d', { a: { b1: { d: 'd' } } }], + [{ a: { b1: { c11: 'd1', c12: 'd2' }, b2: { c21: 'd3', c22: 'd4' } } }, 'a.b1.c11', 'a.b2.c22', { a: { b1: { c12: 'd2' }, b2: { c21: 'd3', c22: 'd1' } } }] + ])( + 'moves a nested value: move(%p, %p, %p) returns %p', + (obj, fromPath, toPath, expected) => { + move(obj, fromPath, toPath) + expect(obj).toEqual(expected) + } + ) +}) diff --git a/prisma/migrations/20250411012334_vault_refactor/migration.sql b/prisma/migrations/20250411012334_vault_refactor/migration.sql index cc2d72155..789c00aaa 100644 --- a/prisma/migrations/20250411012334_vault_refactor/migration.sql +++ b/prisma/migrations/20250411012334_vault_refactor/migration.sql @@ -47,6 +47,16 @@ CREATE TABLE "WalletLNC" ( CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "WalletWebLN" ( + "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, + + CONSTRAINT "WalletWebLN_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE UNIQUE INDEX "WalletBlink_apiKeyId_key" ON "WalletBlink"("apiKeyId"); @@ -77,6 +87,9 @@ CREATE UNIQUE INDEX "WalletLNC_remoteKeyId_key" ON "WalletLNC"("remoteKeyId"); -- CreateIndex CREATE UNIQUE INDEX "WalletLNC_serverHostId_key" ON "WalletLNC"("serverHostId"); +-- CreateIndex +CREATE UNIQUE INDEX "WalletWebLN_walletId_key" ON "WalletWebLN"("walletId"); + -- AddForeignKey ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_adminKeyId_fkey" FOREIGN KEY ("adminKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; @@ -107,6 +120,16 @@ ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_remoteKeyId_fkey" FOREIGN KEY -- AddForeignKey ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_serverHostId_fkey" FOREIGN KEY ("serverHostId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "WalletWebLN" ADD CONSTRAINT "WalletWebLN_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_lnc_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLNC" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE TRIGGER wallet_webln_as_jsonb +AFTER INSERT OR UPDATE ON "WalletWebLN" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); CREATE OR REPLACE FUNCTION migrate_wallet_vault() RETURNS void AS @@ -114,6 +137,9 @@ $$ DECLARE vaultEntry "VaultEntry"%ROWTYPE; BEGIN + INSERT INTO "WalletWebLN"("walletId") SELECT id FROM "Wallet" WHERE type = 'WEBLN'; + INSERT INTO "WalletLNC"("walletId") SELECT id from "Wallet" WHERE type = 'LNC'; + FOR vaultEntry IN SELECT * FROM "VaultEntry" LOOP DECLARE vaultId INT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 760f5aae0..b0c07e4c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -241,6 +241,7 @@ model Wallet { walletPhoenixd WalletPhoenixd? walletBlink WalletBlink? walletLNC WalletLNC? + walletWebLN WalletWebLN? vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] @@ -346,28 +347,28 @@ model WalletLNbits { } model WalletNWC { + 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") + nwcUrlRecv String? + nwcUrlId Int? @unique + nwcUrl Vault? @relation(fields: [nwcUrlId], references: [id]) +} + +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") - nwcUrlRecv String? - nwcUrlId Int? @unique - nwcUrl Vault? @relation(fields: [nwcUrlId], references: [id]) -} - -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? - apiKeyId Int? @unique - apiKey Vault? @relation("blinkApiKeySend", fields: [apiKeyId], references: [id]) - currencyId Int? @unique - currency Vault? @relation("blinkCurrencySend", fields: [currencyId], references: [id]) + apiKeyRecv String? + currencyRecv String? + apiKeyId Int? @unique + apiKey Vault? @relation("blinkApiKeySend", fields: [apiKeyId], references: [id]) + currencyId Int? @unique + currency Vault? @relation("blinkCurrencySend", fields: [currencyId], references: [id]) } model WalletPhoenixd { @@ -398,6 +399,14 @@ model WalletLNC { serverHost Vault? @relation("lncServerHost", fields: [serverHostId], references: [id]) } +model WalletWebLN { + 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") +} + model Mute { muterId Int mutedId Int diff --git a/wallets/vault.js b/wallets/vault.js new file mode 100644 index 000000000..bca17d3dd --- /dev/null +++ b/wallets/vault.js @@ -0,0 +1,134 @@ +import { getWalletByType } from '@/wallets/common' +import walletDefs from '@/wallets/client' +import { get } from '@/lib/object' + +// returns fragments for the Prisma Client API +// input is a wallet row from the db with the new schema +export function vaultPrismaFragments (wallet) { + let initial = { + // fragment to use with models.wallet.update or models.wallet.create + // to include vault rows in the result via new schema + include: vaultPrismaFragmentInclude(wallet) + } + + if (!wallet) { + return initial + } + + const def = getWalletByType(wallet.type) + // template for delete fragments + const del = vaultFieldNames(def).reduce((acc, name) => ({ + ...acc, + [name]: { delete: true } + }), {}) + + initial = { + ...initial, + create: {}, + upsert: {}, + deleteMissing: del, + // XXX we need to create a copy so we don't also mutate this object inside reduce via the delete keyword + deleteAll: { ...del } + } + + if (!wallet.vaultEntries) { + return initial + } + + return wallet.vaultEntries.reduce((acc, { key, iv, value }) => { + delete acc.deleteMissing[key] + return { + // fragment to use within [walletField].create or [walletField].upsert.create + // to create vault rows for a new wallet + create: { + ...acc.create, + [key]: { + create: { iv, value } + } + }, + // fragment to use within [walletField].update or [walletField].upsert.update + // to upsert vault of wallet + upsert: { + ...acc.upsert, + [key]: { + upsert: { + create: { iv, value }, + update: { iv, value } + } + } + }, + // fragments to use within [walletField].update to delete the missing vault rows + // that would not be updated via the upsert fragment + deleteMissing: acc.deleteMissing, + // fragment to use when we want to delete the full vault of a wallet + deleteAll: acc.deleteAll, + // pass-through fragment since it was already created before calling reduce + include: acc.include + } + }, initial) +} + +function vaultPrismaFragmentInclude (wallet) { + const include = {} + + for (const def of walletDefs) { + const names = vaultFieldNames(def) + if (names.length === 0) continue + + include[def.walletField] = { + include: names.reduce((acc, name) => ({ + ...acc, + [name]: true + }), {}) + } + + if (wallet && wallet.type === def.walletType) { + return { + [def.walletField]: include[def.walletField] + } + } + } + + return include +} + +function vaultFieldNames (walletDef) { + return walletDef.fields.filter(f => f.clientOnly).map(f => f.name) +} + +export function vaultNewSchematoTypedef (wallet) { + // this function converts a wallet row from the db with the new schema + // to the expected GraphQL typedef since the client has not yet been updated. + // + // For example, the query for the LNbits wallet now returns the wallet as (url,invoiceKey,adminKey) + // but the client expects wallet and vaultEntries separated, see api/typedefs/wallet.js. + // + // === TODO: remove this function after client update === + const def = getWalletByType(wallet.type) + + const newVaultEntries = [] + for (const name of vaultFieldNames(def)) { + const newVaultEntry = get(wallet, `${def.walletField}.${name}`) + if (newVaultEntry) newVaultEntries.push({ ...newVaultEntry, key: name }) + } + + return { + ...wallet, + vaultEntries: newVaultEntries + } +} + +export function deleteVault (models, wallet) { + const vaultFrags = vaultPrismaFragments(wallet) + const def = getWalletByType(wallet.type) + return models[def.walletField].update({ + where: { walletId: wallet.id }, + data: vaultFrags.deleteAll + }) +} + +export function hasVault (wallet) { + const def = getWalletByType(wallet.type) + const vaultNames = vaultFieldNames(def) + return vaultNames.some(name => get(wallet, `wallet.${name}Id`)) +} diff --git a/worker/wallet.js b/worker/wallet.js index ab504d087..ceff5cc61 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -14,6 +14,7 @@ import { import { payingActionConfirmed, payingActionFailed } from './payingAction' import { canReceive, getWalletByType } from '@/wallets/common' import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' +import { hasVault, vaultPrismaFragments } from '@/wallets/vault' export async function subscribeToWallet (args) { await subscribeToDeposits(args) @@ -296,15 +297,13 @@ export async function checkWallet ({ data: { userId }, models }) { userId, enabled: true }, - include: { - vaultEntries: true - } + include: vaultPrismaFragments().include }) const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) const newHasRecvWallet = wallets.some(({ type, wallet }) => canReceive({ def: getWalletByType(type), config: wallet })) - const newHasSendWallet = wallets.some(({ vaultEntries }) => vaultEntries.length > 0) + const newHasSendWallet = wallets.some(hasVault) await tx.user.update({ where: { id: userId }, From ba2a589f91d928e9f1bd833d25b46f55685e6dae Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 18 Apr 2025 23:31:56 +0200 Subject: [PATCH 3/8] Drop VaultEntry table --- .../migration.sql | 6 +++++- prisma/schema.prisma | 18 ------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/prisma/migrations/20250411012334_vault_refactor/migration.sql b/prisma/migrations/20250411012334_vault_refactor/migration.sql index 789c00aaa..f5bef0600 100644 --- a/prisma/migrations/20250411012334_vault_refactor/migration.sql +++ b/prisma/migrations/20250411012334_vault_refactor/migration.sql @@ -201,4 +201,8 @@ END; $$ LANGUAGE plpgsql; SELECT migrate_wallet_vault(); -DROP FUNCTION migrate_wallet_vault(); \ No newline at end of file +DROP FUNCTION migrate_wallet_vault(); + +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_userId_fkey"; +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_walletId_fkey"; +DROP TABLE "VaultEntry"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0c07e4c6..4baf89a39 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,7 +146,6 @@ model User { oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") vaultKeyHash String @default("") walletsUpdatedAt DateTime? - vaultEntries VaultEntry[] @relation("VaultEntries") proxyReceive Boolean @default(true) directReceive Boolean @default(true) DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") @@ -243,7 +242,6 @@ model Wallet { walletLNC WalletLNC? walletWebLN WalletWebLN? - vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] InvoiceForward InvoiceForward[] DirectPayment DirectPayment[] @@ -270,22 +268,6 @@ model Vault { walletLNCLocalKey WalletLNC? @relation("lncLocalKey") } -model VaultEntry { - id Int @id @default(autoincrement()) - key String @db.Text - iv String @db.Text - value String @db.Text - userId Int - walletId Int? - user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - - @@unique([userId, key]) - @@index([walletId]) -} - model WalletLog { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") From 6c9109e76e4f79f191d55e81182008a80fab53aa Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 21 Apr 2025 00:51:02 +0200 Subject: [PATCH 4/8] Refactor vaultPrismaFragments --- api/resolvers/vault.js | 6 +- api/resolvers/wallet.js | 18 +++--- wallets/vault.js | 129 +++++++++++++++++----------------------- worker/wallet.js | 2 +- 4 files changed, 67 insertions(+), 88 deletions(-) diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index ab7b4374a..9fbf51706 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -7,10 +7,9 @@ export default { getVaultEntries: async (parent, args, { me, models }) => { if (!me) throw new GqlAuthenticationError() - const { include } = vaultPrismaFragments() const wallets = await models.wallet.findMany({ where: { userId: me.id }, - include + include: vaultPrismaFragments.include() }) const vaultEntries = [] @@ -45,13 +44,12 @@ export default { const wallets = await models.wallet.findMany({ where: { userId: me.id } }) for (const wallet of wallets) { const def = getWalletByType(wallet.type) - const vaultFrags = vaultPrismaFragments({ ...wallet, vaultEntries: entries }) txs.push( models.wallet.update({ where: { id: wallet.id }, data: { [def.walletField]: { - update: vaultFrags.upsert + update: vaultPrismaFragments.upsert({ ...wallet, vaultEntries: entries }) } } }) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 9f1b65346..9454b3f68 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -49,7 +49,7 @@ function injectResolvers (resolvers) { where: { id: Number(data.id) }, - include: vaultPrismaFragments().include + include: vaultPrismaFragments.include() }) existingVaultEntries = vaultNewSchematoTypedef(wallet).vaultEntries } @@ -164,7 +164,7 @@ const resolvers = { } const wallets = await models.wallet.findMany({ - include: vaultPrismaFragments().include, + include: vaultPrismaFragments.include(), where: { userId: me.id }, @@ -846,7 +846,7 @@ async function upsertWallet ( const txs = [] - const vaultFrags = vaultPrismaFragments({ ...wallet, vaultEntries }) + const walletWithVault = { ...wallet, vaultEntries } if (id) { const dbWallet = await models.wallet.findUnique({ @@ -861,26 +861,26 @@ async function upsertWallet ( priority, [wallet.field]: { upsert: { - create: { ...recvConfig, ...vaultFrags?.create }, - update: { ...recvConfig, ...vaultFrags?.upsert } + create: { ...recvConfig, ...vaultPrismaFragments.create(walletWithVault) }, + update: { ...recvConfig, ...vaultPrismaFragments.upsert(walletWithVault) } }, // XXX the check is required because the update would fail if there is no row to delete ... - update: hasVault(dbWallet) ? vaultFrags?.deleteMissing : undefined + update: hasVault(dbWallet) ? vaultPrismaFragments.deleteMissing(walletWithVault) : undefined } }, - include: vaultFrags?.include + include: vaultPrismaFragments.include(walletWithVault) }) ) } else { txs.push( models.wallet.create({ - include: vaultFrags?.include, + include: vaultPrismaFragments.include(walletWithVault), data: { enabled, priority, userId: me.id, type: wallet.type, - [wallet.field]: { create: { ...recvConfig, ...vaultFrags?.create } } + [wallet.field]: { create: { ...recvConfig, ...vaultPrismaFragments.create(walletWithVault) } } } }) ) diff --git a/wallets/vault.js b/wallets/vault.js index bca17d3dd..386a18b87 100644 --- a/wallets/vault.js +++ b/wallets/vault.js @@ -2,91 +2,73 @@ import { getWalletByType } from '@/wallets/common' import walletDefs from '@/wallets/client' import { get } from '@/lib/object' -// returns fragments for the Prisma Client API -// input is a wallet row from the db with the new schema -export function vaultPrismaFragments (wallet) { - let initial = { - // fragment to use with models.wallet.update or models.wallet.create - // to include vault rows in the result via new schema - include: vaultPrismaFragmentInclude(wallet) - } - - if (!wallet) { - return initial - } +export const vaultPrismaFragments = { + create: createFragment, + upsert: upsertFragment, + deleteMissing: deleteMissingFragment, + deleteAll: deleteAllFragment, + include: includeFragment +} - const def = getWalletByType(wallet.type) - // template for delete fragments - const del = vaultFieldNames(def).reduce((acc, name) => ({ +function createFragment (wallet) { + return wallet.vaultEntries?.reduce((acc, { key, iv, value }) => ({ ...acc, - [name]: { delete: true } + [key]: { + create: { iv, value } + } }), {}) +} - initial = { - ...initial, - create: {}, - upsert: {}, - deleteMissing: del, - // XXX we need to create a copy so we don't also mutate this object inside reduce via the delete keyword - deleteAll: { ...del } - } - - if (!wallet.vaultEntries) { - return initial - } - - return wallet.vaultEntries.reduce((acc, { key, iv, value }) => { - delete acc.deleteMissing[key] - return { - // fragment to use within [walletField].create or [walletField].upsert.create - // to create vault rows for a new wallet - create: { - ...acc.create, - [key]: { - create: { iv, value } - } - }, - // fragment to use within [walletField].update or [walletField].upsert.update - // to upsert vault of wallet +function upsertFragment (wallet) { + return wallet.vaultEntries?.reduce((acc, { key, iv, value }) => ({ + ...acc, + [key]: { upsert: { - ...acc.upsert, - [key]: { - upsert: { - create: { iv, value }, - update: { iv, value } - } - } - }, - // fragments to use within [walletField].update to delete the missing vault rows - // that would not be updated via the upsert fragment - deleteMissing: acc.deleteMissing, - // fragment to use when we want to delete the full vault of a wallet - deleteAll: acc.deleteAll, - // pass-through fragment since it was already created before calling reduce - include: acc.include + create: { iv, value }, + update: { iv, value } + } } - }, initial) + }), {}) } -function vaultPrismaFragmentInclude (wallet) { - const include = {} +function deleteMissingFragment (wallet) { + const del = deleteAllFragment(wallet) + for (const { key: name } of wallet.vaultEntries) { + delete del[name] + } + return del +} - for (const def of walletDefs) { - const names = vaultFieldNames(def) - if (names.length === 0) continue +function deleteAllFragment (wallet) { + const def = getWalletByType(wallet.type) + const names = vaultFieldNames(def) + return names.reduce((acc, name) => ({ + ...acc, + [name]: { delete: true } + }), {}) +} - include[def.walletField] = { - include: names.reduce((acc, name) => ({ - ...acc, - [name]: true - }), {}) - } +function includeFragment (wallet) { + const include = walletDefs.reduce((acc, def) => { + const names = vaultFieldNames(def) + if (names.length === 0) return acc - if (wallet && wallet.type === def.walletType) { - return { - [def.walletField]: include[def.walletField] + return { + ...acc, + [def.walletField]: { + include: names.reduce((acc2, name) => ({ + ...acc2, + [name]: true + }), {}) } } + }, {}) + + if (wallet) { + const def = getWalletByType(wallet.type) + const names = vaultFieldNames(def) + if (names.length === 0) return {} + return { [def.walletField]: include[def.walletField] } } return include @@ -119,11 +101,10 @@ export function vaultNewSchematoTypedef (wallet) { } export function deleteVault (models, wallet) { - const vaultFrags = vaultPrismaFragments(wallet) const def = getWalletByType(wallet.type) return models[def.walletField].update({ where: { walletId: wallet.id }, - data: vaultFrags.deleteAll + data: vaultPrismaFragments.deleteAll(wallet) }) } diff --git a/worker/wallet.js b/worker/wallet.js index ceff5cc61..49dea5abd 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -297,7 +297,7 @@ export async function checkWallet ({ data: { userId }, models }) { userId, enabled: true }, - include: vaultPrismaFragments().include + include: vaultPrismaFragments.include() }) const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) From 5894f2e3d3977ba1d4e2ad4b891f7d2d8d51cc04 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 21 Apr 2025 02:33:34 +0200 Subject: [PATCH 5/8] Remove wrong comment --- api/resolvers/wallet.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 9454b3f68..882aa8d66 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -917,8 +917,6 @@ async function upsertWallet ( } const [upsertedWallet] = await models.$transaction(txs) - - // migrate from old schema to new schema for vault return vaultNewSchematoTypedef(upsertedWallet) } From 226623c19ddabf3fdb6f7779e7f0ed0937f3bb77 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 21 Apr 2025 02:44:32 +0200 Subject: [PATCH 6/8] Remove TODO --- api/resolvers/wallet.js | 1 - 1 file changed, 1 deletion(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 882aa8d66..663a5f89d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -44,7 +44,6 @@ function injectResolvers (resolvers) { // this mutation was sent from an unsynced client // to pass validation, we need to add the existing vault entries for validation // in case the client is removing the receiving config - // TODO: replace this usage because of new schema const wallet = await models.wallet.findUnique({ where: { id: Number(data.id) From 9ab88561a88c56090d0e7eb25f38b65ff02da9bb Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 24 Apr 2025 01:07:29 +0200 Subject: [PATCH 7/8] Fix possible race condition on update of vault key --- api/resolvers/vault.js | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 9fbf51706..a1258605d 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -25,27 +25,20 @@ export default { updateVaultKey: async (parent, { entries, hash }, { me, models }) => { if (!me) throw new GqlAuthenticationError() if (!hash) throw new GqlInputError('hash required') - const txs = [] const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) if (oldKeyHash) { - if (oldKeyHash !== hash) { - throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) - } else { + if (oldKeyHash === hash) { return true } - } else { - txs.push(models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: hash } - })) + throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) } - const wallets = await models.wallet.findMany({ where: { userId: me.id } }) - for (const wallet of wallets) { - const def = getWalletByType(wallet.type) - txs.push( - models.wallet.update({ + return await models.$transaction(async tx => { + const wallets = await tx.wallet.findMany({ where: { userId: me.id } }) + for (const wallet of wallets) { + const def = getWalletByType(wallet.type) + await tx.wallet.update({ where: { id: wallet.id }, data: { [def.walletField]: { @@ -53,11 +46,16 @@ export default { } } }) - ) - } + } - await models.$transaction(txs) - return true + // optimistic concurrency control: make sure the user's vault key didn't change while we were updating the wallets + await tx.user.update({ + where: { id: me.id, vaultKeyHash: oldKeyHash }, + data: { vaultKeyHash: hash } + }) + + return true + }) }, clearVault: async (parent, args, { me, models }) => { if (!me) throw new GqlAuthenticationError() From a10af104f3482adbc88a64f5c3b9cd06128dd07b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 30 Apr 2025 15:09:19 -0500 Subject: [PATCH 8/8] Remove lib/object.js --- lib/object.js | 25 ------------------------ lib/object.spec.js | 47 ---------------------------------------------- wallets/vault.js | 5 ++--- 3 files changed, 2 insertions(+), 75 deletions(-) delete mode 100644 lib/object.js delete mode 100644 lib/object.spec.js diff --git a/lib/object.js b/lib/object.js deleted file mode 100644 index ada24f7f2..000000000 --- a/lib/object.js +++ /dev/null @@ -1,25 +0,0 @@ -export const get = (obj, path) => { - if (!path) return obj - const keys = path.split('.') - return keys.reduce((obj, key) => obj?.[key], obj) -} - -export const set = (obj, path, value) => { - const keys = path.split('.') - const lastKey = keys.pop() - const parent = get(obj, keys.join('.')) - parent[lastKey] = value -} - -export const remove = (obj, path) => { - const keys = path.split('.') - const lastKey = keys.pop() - const parent = get(obj, keys.join('.')) - delete parent?.[lastKey] -} - -export const move = (obj, fromPath, toPath) => { - const value = get(obj, fromPath) - remove(obj, fromPath) - set(obj, toPath, value) -} diff --git a/lib/object.spec.js b/lib/object.spec.js deleted file mode 100644 index 2b26cfe11..000000000 --- a/lib/object.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-env jest */ - -import { get, move, remove, set } from './object' - -describe('object helpers', () => { - test.each([ - [{ a: 'b' }, '', { a: 'b' }], - [{ a: 'b' }, 'a', 'b'], - [{ a: { b: { c: 'd' } } }, 'a.b', { c: 'd' }] - ])( - 'gets a nested value: get(%p, %p) returns %p', - (obj, path, expected) => { - expect(get(obj, path)).toEqual(expected) - }) - - test.each([ - [{ a: 'b' }, '', { a: 'b' }], - [{ a: { b: { c: 'd' } } }, 'a.b.c', 'e', { a: { b: { c: 'e' } } }] - ])( - 'sets a nested value: set(%p, %p, %p) returns %p', - () => { - const obj = { a: { b: { c: 'd' } } } - set(obj, 'a.b.c', 'e') - expect(obj).toEqual({ a: { b: { c: 'e' } } }) - }) - - test.each([ - [{ a: 'b' }, 'a', {}], - [{ a: { b: { c: 'd' } } }, 'a.b.c', { a: { b: {} } }] - ])( - 'removes a nested values: remove(%p, %p) returns %p', - (obj, path, expected) => { - remove(obj, path) - expect(obj).toEqual(expected) - }) - - test.each([ - [{ a: { b1: { c: 'd' } } }, 'a.b1.c', 'a.b1.d', { a: { b1: { d: 'd' } } }], - [{ a: { b1: { c11: 'd1', c12: 'd2' }, b2: { c21: 'd3', c22: 'd4' } } }, 'a.b1.c11', 'a.b2.c22', { a: { b1: { c12: 'd2' }, b2: { c21: 'd3', c22: 'd1' } } }] - ])( - 'moves a nested value: move(%p, %p, %p) returns %p', - (obj, fromPath, toPath, expected) => { - move(obj, fromPath, toPath) - expect(obj).toEqual(expected) - } - ) -}) diff --git a/wallets/vault.js b/wallets/vault.js index 386a18b87..e99339a5a 100644 --- a/wallets/vault.js +++ b/wallets/vault.js @@ -1,6 +1,5 @@ import { getWalletByType } from '@/wallets/common' import walletDefs from '@/wallets/client' -import { get } from '@/lib/object' export const vaultPrismaFragments = { create: createFragment, @@ -90,7 +89,7 @@ export function vaultNewSchematoTypedef (wallet) { const newVaultEntries = [] for (const name of vaultFieldNames(def)) { - const newVaultEntry = get(wallet, `${def.walletField}.${name}`) + const newVaultEntry = wallet?.[def.walletField]?.[name] if (newVaultEntry) newVaultEntries.push({ ...newVaultEntry, key: name }) } @@ -111,5 +110,5 @@ export function deleteVault (models, wallet) { export function hasVault (wallet) { const def = getWalletByType(wallet.type) const vaultNames = vaultFieldNames(def) - return vaultNames.some(name => get(wallet, `wallet.${name}Id`)) + return vaultNames.some(name => wallet.wallet?.[`${name}Id`]) }