From e63c45c88cc44f19f7d97f1707830c1f5e0c1b3a Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Wed, 21 Jan 2026 18:39:44 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20server:=20poke=20account=20afte?= =?UTF-8?q?r=20kyc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/silly-yaks-divide.md | 5 + server/hooks/activity.ts | 174 ++++++++---------- server/hooks/persona.ts | 23 ++- server/test/hooks/activity.test.ts | 149 --------------- server/test/hooks/persona.test.ts | 280 ++++++++++++++++++++++++++++- server/utils/persona.ts | 119 +++++++++++- src/utils/persona.ts | 2 +- 7 files changed, 489 insertions(+), 263 deletions(-) create mode 100644 .changeset/silly-yaks-divide.md diff --git a/.changeset/silly-yaks-divide.md b/.changeset/silly-yaks-divide.md new file mode 100644 index 000000000..d3f7700c5 --- /dev/null +++ b/.changeset/silly-yaks-divide.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ poke account after kyc diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index f3f022a48..d7b7f8501 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -14,16 +14,12 @@ import createDebug from "debug"; import { eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import * as v from "valibot"; -import { bytesToBigInt, withRetry } from "viem"; +import { bytesToBigInt } from "viem"; import { - auditorAbi, exaAccountFactoryAbi, - exaPluginAbi, exaPreviewerAbi, exaPreviewerAddress, - marketAbi, - upgradeableModularAccountAbi, wethAddress, } from "@exactly/common/generated/chain"; import { Address, Hash } from "@exactly/common/validation"; @@ -35,6 +31,7 @@ import decodePublicKey from "../utils/decodePublicKey"; import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; import { autoCredit } from "../utils/panda"; +import { pokeAccountAssets } from "../utils/persona"; import publicClient from "../utils/publicClient"; import { track } from "../utils/segment"; import validatorHook from "../utils/validatorHook"; @@ -107,9 +104,11 @@ export default new Hono().post( .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) .then((p) => new Map(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)]))); const markets = new Set(marketsByAsset.values()); - const pokes = new Map; factory: Address; publicKey: Uint8Array }>(); + + const accountsToProcess = new Set
(); for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) { if (!accounts[account]) continue; + // skip notifications for market share transfers if (rawContract?.address && markets.has(rawContract.address)) continue; const asset = rawContract?.address ?? ETH; const underlying = asset === ETH ? WETH : asset; @@ -120,112 +119,81 @@ export default new Hono().post( en: `${value ? `${value} ` : ""}${assetSymbol} received${marketsByAsset.has(underlying) ? " and instantly started earning yield" : ""}`, }, }).catch((error: unknown) => captureException(error)); - - if (pokes.has(account)) { - pokes.get(account)?.assets.add(asset); - } else { - const { publicKey, factory } = accounts[account]; - pokes.set(account, { publicKey, factory, assets: new Set([asset]) }); - } + accountsToProcess.add(account); } const { "sentry-trace": sentryTrace, baggage } = getTraceData(); Promise.allSettled( - [...pokes.entries()].map(([account, { publicKey, factory, assets }]) => - continueTrace({ sentryTrace, baggage }, () => - withScope((scope) => - startSpan( - { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true }, - async (span) => { - scope.setUser({ id: account }); - scope.setTag("exa.account", account); - const isDeployed = !!(await publicClient.getCode({ address: account })); - scope.setTag("exa.new", !isDeployed); - if (!isDeployed) { - try { - await keeper.exaSend( - { name: "create account", op: "exa.account", attributes: { account } }, - { - address: factory, - functionName: "createAccount", - args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]], - abi: exaAccountFactoryAbi, - }, - ); - track({ event: "AccountFunded", userId: account }); - } catch (error: unknown) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" }); - throw error; - } - } - if (assets.has(ETH)) assets.delete(WETH); - const results = await Promise.allSettled( - [...assets] - .filter((asset) => marketsByAsset.has(asset) || asset === ETH) - .map(async (asset) => - withRetry( - () => - keeper.exaSend( - { name: "poke account", op: "exa.poke", attributes: { account, asset } }, - { - address: account, - abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi], - ...(asset === ETH - ? { functionName: "pokeETH" } - : { - functionName: "poke", - args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion - }), - }, - ), + [...accountsToProcess] + .map((account) => { + const accountInfo = accounts[account]; + if (!accountInfo) return null; + const { publicKey, factory } = accountInfo; + return [account, { publicKey, factory }] as const; + }) + .filter((item): item is [Address, { factory: Address; publicKey: Uint8Array }] => item !== null) + .map(([account, { publicKey, factory }]) => + continueTrace({ sentryTrace, baggage }, () => + withScope((scope) => + startSpan( + { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true }, + async (span) => { + scope.setUser({ id: account }); + scope.setTag("exa.account", account); + const isDeployed = !!(await publicClient.getCode({ address: account })); + scope.setTag("exa.new", !isDeployed); + if (!isDeployed) { + try { + await keeper.exaSend( + { name: "create account", op: "exa.account", attributes: { account } }, { - delay: 2000, - retryCount: 5, - shouldRetry: ({ error }) => { - captureException(error, { level: "error" }); - return true; - }, + address: factory, + functionName: "createAccount", + args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]], + abi: exaAccountFactoryAbi, }, - ), - ), - ); - for (const result of results) { - if (result.status === "fulfilled") continue; - span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" }); - throw result.reason; - } - autoCredit(account) - .then(async (auto) => { - span.setAttribute("exa.autoCredit", auto); - if (!auto) return; - const credential = await database.query.credentials.findFirst({ - where: eq(credentials.account, account), - columns: {}, - with: { - cards: { - columns: { id: true, mode: true }, - where: inArray(cards.status, ["ACTIVE", "FROZEN"]), + ); + track({ event: "AccountFunded", userId: account }); + } catch (error: unknown) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" }); + throw error; + } + } + await pokeAccountAssets(account, { ignore: [`NotAllowed(${account})`] }).catch((error: unknown) => + captureException(error), + ); + autoCredit(account) + .then(async (auto) => { + span.setAttribute("exa.autoCredit", auto); + if (!auto) return; + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.account, account), + columns: {}, + with: { + cards: { + columns: { id: true, mode: true }, + where: inArray(cards.status, ["ACTIVE", "FROZEN"]), + }, }, - }, - }); - if (!credential || credential.cards.length === 0) return; - const card = credential.cards[0]; - span.setAttribute("exa.card", card?.id); - if (card?.mode !== 0) return; - await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id)); - span.setAttribute("exa.mode", 1); - sendPushNotification({ - userId: account, - headings: { en: "Card mode changed" }, - contents: { en: "Credit mode activated" }, - }).catch((error: unknown) => captureException(error)); - }) - .catch((error: unknown) => captureException(error)); - span.setStatus({ code: SPAN_STATUS_OK }); - }, + }); + if (!credential || credential.cards.length === 0) return; + const card = credential.cards[0]; + span.setAttribute("exa.card", card?.id); + if (card?.mode !== 0) return; + await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id)); + span.setAttribute("exa.mode", 1); + sendPushNotification({ + userId: account, + headings: { en: "Card mode changed" }, + contents: { en: "Credit mode activated" }, + }).catch((error: unknown) => captureException(error)); + }) + .catch((error: unknown) => captureException(error)); + span.setStatus({ code: SPAN_STATUS_OK }); + }, + ), ), ), ), - ), ) .then((results) => { let status: SpanStatus = { code: SPAN_STATUS_OK }; diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index a3f0c130a..1231be1e2 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -2,6 +2,7 @@ import { vValidator } from "@hono/valibot-validator"; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, setContext, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import * as v from "valibot"; import { array, check, @@ -25,11 +26,18 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; -import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; +import { + addDocument, + headerValidator, + MANTECA_TEMPLATE_WITH_ID_CLASS, + PANDA_TEMPLATE, + pokeAccountAssets, +} from "../utils/persona"; import { customer } from "../utils/sardine"; import validatorHook from "../utils/validatorHook"; import type { InferOutput } from "valibot"; + const Session = pipe( object({ type: literal("inquiry-session"), @@ -289,8 +297,15 @@ export default new Hono().post( getActiveSpan()?.setAttributes({ "exa.pandaId": id }); setContext("persona", { inquiryId: personaShareToken, pandaId: id }); - const account = safeParse(Address, credential.account); - if (account.success) { + Promise.resolve() + .then(async () => { + const accountAddress = v.parse(Address, credential.account); + await pokeAccountAssets(accountAddress, { ignore: [`NotAllowed(${accountAddress})`] }); + }) + .catch((error: unknown) => captureException(error)); + + const accountParsed = safeParse(Address, credential.account); + if (accountParsed.success) { addCapita({ birthdate: fields.birthdate.value, document: fields.identificationNumber.value, @@ -298,7 +313,7 @@ export default new Hono().post( lastName: fields.nameLast.value, email: fields.emailAddress.value, phone: fields.phoneNumber?.value ?? "", - internalId: deriveAssociateId(account.output), + internalId: deriveAssociateId(accountParsed.output), product: "travel insurance", }).catch((error: unknown) => { captureException(error, { level: "error", extra: { pandaId: id, referenceId } }); diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 2846d45a5..f44568521 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -8,7 +8,6 @@ import { captureException } from "@sentry/node"; import { testClient } from "hono/testing"; import { bytesToHex, - hexToBigInt, hexToBytes, padHex, parseEther, @@ -21,12 +20,10 @@ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; -import { exaAccountFactoryAbi, previewerAbi } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/activity"; import * as decodePublicKey from "../../utils/decodePublicKey"; -import keeper from "../../utils/keeper"; import * as onesignal from "../../utils/onesignal"; import publicClient from "../../utils/publicClient"; import anvilClient from "../anvilClient"; @@ -106,142 +103,6 @@ describe("address activity", () => { expect(response.status).toBe(200); }); - it("pokes eth", async () => { - const deposit = parseEther("5"); - await anvilClient.setBalance({ address: account, value: deposit }); - - const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt"); - - const response = await appClient.index.$post({ - ...activityPayload, - json: { - ...activityPayload.json, - event: { - ...activityPayload.json.event, - activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }], - }, - }, - }); - - await vi.waitUntil( - () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 2, - 26_666, - ); - - const exactly = await publicClient.readContract({ - address: inject("Previewer"), - functionName: "exactly", - abi: previewerAbi, - args: [account], - }); - - const market = exactly.find((m) => m.asset === inject("WETH")); - - expect(market?.floatingDepositAssets).toBe(deposit); - expect(market?.isCollateral).toBe(true); - expect(response.status).toBe(200); - }); - - it("pokes weth and eth", async () => { - const eth = parseEther("5"); - await anvilClient.setBalance({ address: account, value: eth }); - - const weth = parseEther("2"); - await keeper.exaSend( - { name: "mint", op: "tx.mint" }, - { address: inject("WETH"), abi: mockERC20Abi, functionName: "mint", args: [account, weth] }, - ); - - const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt"); - - const response = await appClient.index.$post({ - ...activityPayload, - json: { - ...activityPayload.json, - event: { - ...activityPayload.json.event, - activity: [ - { ...activityPayload.json.event.activity[0], toAddress: account }, - { - ...activityPayload.json.event.activity[1], - toAddress: account, - rawContract: { ...activityPayload.json.event.activity[1].rawContract, address: inject("WETH") }, - }, - ], - }, - }, - }); - - await vi.waitUntil( - () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 2, - 26_666, - ); - - const exactly = await publicClient.readContract({ - address: inject("Previewer"), - functionName: "exactly", - abi: previewerAbi, - args: [account], - }); - - const market = exactly.find((m) => m.asset === inject("WETH")); - - expect(market?.floatingDepositAssets).toBe(eth + weth); - expect(market?.isCollateral).toBe(true); - expect(response.status).toBe(200); - }); - - it("pokes multiple accounts", async () => { - const owners = [ - owner, - privateKeyToAccount(generatePrivateKey()), - privateKeyToAccount(generatePrivateKey()), - ] as const; - const accounts = owners.map(({ address }) => - deriveAddress(inject("ExaAccountFactory"), { x: padHex(address), y: zeroHash }), - ); - await Promise.all([ - ...accounts.slice(1).map((id) => - database.insert(credentials).values({ - id, - publicKey: new Uint8Array(hexToBytes(id)), - account: id, - factory: inject("ExaAccountFactory"), - }), - ), - ...accounts.map((address) => anvilClient.setBalance({ address, value: parseEther("5") })), - keeper.exaSend( - { name: "create account", op: "exa.account" }, - { - address: inject("ExaAccountFactory"), - abi: exaAccountFactoryAbi, - functionName: "createAccount", - args: [0n, [{ x: hexToBigInt(owners[0].address), y: 0n }]], - }, - ), - ]); - - const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt"); - const [response] = await Promise.all([ - appClient.index.$post({ - ...activityPayload, - json: { - ...activityPayload.json, - event: { - ...activityPayload.json.event, - activity: accounts.map((toAddress) => ({ ...activityPayload.json.event.activity[0], toAddress })), - }, - }, - }), - vi.waitUntil( - () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 5, - 26_666, - ), - ]); - - expect(response.status).toBe(200); - }); - it("deploy account for non market asset", async () => { const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt"); @@ -346,13 +207,3 @@ afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); - -const mockERC20Abi = [ - { - type: "function", - name: "mint", - inputs: [{ type: "address" }, { type: "uint256" }], - outputs: [], - stateMutability: "nonpayable", - }, -] as const; diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 6c3774e02..177266f36 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -5,17 +5,20 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { hexToBytes, padHex, zeroHash } from "viem"; +import { hexToBytes, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; +import { wethAddress } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/persona"; +import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; +import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; const appClient = testClient(app); @@ -381,7 +384,11 @@ describe("persona hook", () => { }); }); - afterEach(() => vi.resetAllMocks()); + afterEach(async () => { + // reset pandaId to null for next test + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "persona-ref")); + vi.restoreAllMocks(); + }); it("creates panda and pax user on valid inquiry", async () => { vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); @@ -389,7 +396,9 @@ describe("persona hook", () => { vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); const response = await appClient.index.$post({ - header: { "persona-signature": "t=1,v1=sha256" }, + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, json: { ...validPayload, data: { @@ -428,6 +437,270 @@ describe("persona hook", () => { product: "travel insurance", }); }); + + it("pokes assets when balances are positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) // assets from exaPreviewerAddress + .mockResolvedValueOnce(parseEther("2")); // balanceOf for the asset + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => exaSendSpy.mock.calls.length >= 2, { timeout: 5000 }); + + expect(exaSendSpy).toHaveBeenNthCalledWith( + 1, + { + name: "poke account", + op: "exa.poke", + attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, + }, + { + address: account, + abi: expect.any(Array) as unknown[], + functionName: "pokeETH", + }, + { + ignore: [`NotAllowed(${account})`], + }, + ); + expect(exaSendSpy).toHaveBeenNthCalledWith( + 2, + { + name: "poke account", + op: "exa.poke", + attributes: { account, asset: "0x1234567890123456789012345678901234567890" }, + }, + { + address: account, + abi: expect.any(Array) as unknown[], + functionName: "poke", + args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"], + }, + { + ignore: [`NotAllowed(${account})`], + }, + ); + }); + + it("pokes only eth when balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([{ asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }]) // assets from exaPreviewerAddress + .mockResolvedValueOnce(0n); // balanceOf for WETH is 0 + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => exaSendSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(exaSendSpy).toHaveBeenCalledTimes(1); + expect(exaSendSpy).toHaveBeenCalledWith( + { + name: "poke account", + op: "exa.poke", + attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, + }, + { + address: account, + abi: expect.any(Array) as unknown[], + functionName: "pokeETH", + }, + { + ignore: [`NotAllowed(${account})`], + }, + ); + }); + + it("skips weth when eth balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) // assets from exaPreviewerAddress + .mockResolvedValueOnce(parseEther("5")) // balanceOf for WETH + .mockResolvedValueOnce(parseEther("2")); // balanceOf for other asset + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => exaSendSpy.mock.calls.length >= 2, { timeout: 5000 }); + + // should poke ETH and the other asset, but skip WETH + expect(exaSendSpy).toHaveBeenCalledTimes(2); + expect(exaSendSpy).toHaveBeenCalledWith( + { + name: "poke account", + op: "exa.poke", + attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, + }, + { + address: account, + abi: expect.any(Array) as unknown[], + functionName: "pokeETH", + }, + { + ignore: [`NotAllowed(${account})`], + }, + ); + expect(exaSendSpy).toHaveBeenCalledWith( + { + name: "poke account", + op: "exa.poke", + attributes: { account, asset: "0x1234567890123456789012345678901234567890" }, + }, + { + address: account, + abi: expect.any(Array) as unknown[], + functionName: "poke", + args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"], + }, + { + ignore: [`NotAllowed(${account})`], + }, + ); + }); + + it("does not poke when balances are zero", async () => { + const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) // assets from exaPreviewerAddress + .mockResolvedValueOnce(0n); // balanceOf is 0 + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(0n); // ETH balance is 0 + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + // use vi.waitFor to ensure no pokes happen with a reasonable timeout + // the wait is necessary because pokeAccountAssets is called asynchronously + await vi.waitFor( + () => { + expect(exaSendSpy).not.toHaveBeenCalled(); + }, + { timeout: 500, interval: 50 }, + ); + }); }); describe("manteca template", () => { @@ -447,6 +720,7 @@ describe("manteca template", () => { it("handles manteca template and adds document", async () => { vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_manteca" } }); + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "should-not-be-called" }); const response = await appClient.index.$post({ header: { "persona-signature": "t=1,v1=sha256" }, diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 578274928..055e8a45d 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -1,5 +1,5 @@ import { vValidator } from "@hono/valibot-validator"; -import { captureEvent, setContext } from "@sentry/core"; +import { captureEvent, captureException, setContext } from "@sentry/core"; import { createHmac, timingSafeEqual } from "node:crypto"; import { array, @@ -17,10 +17,23 @@ import { type BaseSchema, type InferOutput, } from "valibot"; - -import chain from "@exactly/common/generated/chain"; +import * as v from "valibot"; +import { erc20Abi, withRetry } from "viem"; + +import chain, { + auditorAbi, + exaPluginAbi, + exaPreviewerAbi, + exaPreviewerAddress, + marketAbi, + upgradeableModularAccountAbi, + wethAddress, +} from "@exactly/common/generated/chain"; +import { Address } from "@exactly/common/validation"; import appOrigin from "./appOrigin"; +import keeper from "./keeper"; +import publicClient from "./publicClient"; import { DevelopmentChainIds } from "./ramps/shared"; if (!process.env.PERSONA_API_KEY) throw new Error("missing persona api key"); @@ -38,6 +51,106 @@ const authorization = `Bearer ${process.env.PERSONA_API_KEY}`; const baseURL = process.env.PERSONA_URL; const webhookSecret = process.env.PERSONA_WEBHOOK_SECRET; +export async function pokeAccountAssets(accountAddress: Address, options?: { ignore?: string[] }) { + const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; + + const marketsByAsset = await withRetry( + () => publicClient.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }), + { + delay: 2000, + retryCount: 5, + shouldRetry: ({ error }) => { + captureException(error, { level: "error" }); + return true; + }, + }, + ).then((p) => new Map(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)]))); + const WETH = v.parse(Address, wethAddress); + const ETH = v.parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + + const assetsToPoke: { asset: Address; market: Address | null }[] = []; + + const [ethBalance, assetBalances] = await Promise.all([ + withRetry(() => publicClient.getBalance({ address: accountAddress }), { + delay: 2000, + retryCount: 5, + shouldRetry: ({ error }) => { + captureException(error, { level: "error" }); + return true; + }, + }), + Promise.all( + [...marketsByAsset.entries()].map(async ([asset, market]) => { + try { + const balance = await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }); + return { asset, market, balance }; + } catch (error) { + captureException(error, { level: "error" }); + return { asset, market, balance: 0n }; + } + }), + ), + ]); + + const hasETH = ethBalance > 0n; + + if (hasETH) { + assetsToPoke.push({ asset: ETH, market: null }); + } + + for (const { asset, market, balance } of assetBalances) { + if (hasETH && asset === WETH) continue; + + if (balance > 0n) { + assetsToPoke.push({ asset, market }); + } + } + + const pokePromises = assetsToPoke.map(({ asset, market }) => + withRetry( + () => + keeper.exaSend( + { + name: "poke account", + op: "exa.poke", + attributes: { account: accountAddress, asset }, + }, + asset === ETH + ? { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "pokeETH", + } + : { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "poke", + args: [market], + }, + ...(options?.ignore ? [{ ignore: options.ignore }] : []), + ), + { + delay: 2000, + retryCount: 5, + shouldRetry: ({ error }) => { + captureException(error, { level: "error" }); + return true; + }, + }, + ), + ); + + const results = await Promise.allSettled(pokePromises); + for (const result of results) { + if (result.status === "rejected") captureException(result.reason); + } +} + export async function getInquiry(referenceId: string, templateId: string) { const { data: approvedInquiries } = await request( GetInquiriesResponse, diff --git a/src/utils/persona.ts b/src/utils/persona.ts index 17439e97a..fc5e6a3fa 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -58,7 +58,7 @@ export function startKYC() { handleCancel(); resolve(); }, - onError: (error) => { + onError: (error: unknown) => { signal.removeEventListener("abort", onAbort); client.destroy(); reportError(error); From 62f5d176f7311f149167a6dcab69f0b842f821eb Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 20 Jan 2026 17:26:24 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20server:=20use=20gcp=20kms=20for?= =?UTF-8?q?=20allower?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/lucky-jokes-change.md | 5 + .do/app.yaml | 13 + cspell.json | 1 + pnpm-lock.yaml | 426 +++++++++++++++++++++++++++++- server/hooks/activity.ts | 41 +-- server/hooks/persona.ts | 33 ++- server/index.ts | 2 + server/package.json | 2 + server/test/hooks/persona.test.ts | 61 +++-- server/test/utils/allower.test.ts | 90 +++++++ server/test/utils/gcp.test.ts | 50 ++++ server/utils/allower.ts | 104 ++++++++ server/utils/gcp.ts | 143 ++++++++++ server/utils/keeper.ts | 4 +- server/utils/persona.ts | 48 +++- server/vitest.config.mts | 4 + 16 files changed, 969 insertions(+), 58 deletions(-) create mode 100644 .changeset/lucky-jokes-change.md create mode 100644 server/test/utils/allower.test.ts create mode 100644 server/test/utils/gcp.test.ts create mode 100644 server/utils/allower.ts create mode 100644 server/utils/gcp.ts diff --git a/.changeset/lucky-jokes-change.md b/.changeset/lucky-jokes-change.md new file mode 100644 index 000000000..ec7d16fad --- /dev/null +++ b/.changeset/lucky-jokes-change.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ use gcp kms for allower diff --git a/.do/app.yaml b/.do/app.yaml index 28204aac2..cce0de126 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -81,6 +81,19 @@ services: - key: DEBUG scope: RUN_TIME value: ${{ env.DEBUG }} + - key: GCP_KMS_KEY_RING + scope: RUN_TIME + value: ${{ env.GCP_KMS_KEY_RING }} + - key: GCP_KMS_KEY_VERSION + scope: RUN_TIME + value: ${{ env.GCP_KMS_KEY_VERSION }} + - key: GCP_PROJECT_ID + scope: RUN_TIME + value: ${{ env.GCP_PROJECT_ID }} + - key: GCP_BASE64_JSON + scope: RUN_TIME + type: SECRET + value: ${{ env.ENCRYPTED_GCP_BASE64_JSON || env.GCP_BASE64_JSON }} - key: INTERCOM_IDENTITY_KEY scope: RUN_TIME type: SECRET diff --git a/cspell.json b/cspell.json index e6a921dea..259579def 100644 --- a/cspell.json +++ b/cspell.json @@ -165,6 +165,7 @@ "valibot", "valierror", "valkey", + "valora", "viem", "viewability", "wagmi", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd3d3eb00..619d7539d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,9 @@ importers: '@exactly/lib': specifier: ^0.1.0 version: 0.1.0 + '@google-cloud/kms': + specifier: ^5.3.0 + version: 5.3.0 '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -735,6 +738,9 @@ importers: '@valibot/to-json-schema': specifier: ^1.5.0 version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + '@valora/viem-account-hsm-gcp': + specifier: ^1.2.16 + version: 1.2.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -2954,6 +2960,19 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google-cloud/kms@5.3.0': + resolution: {integrity: sha512-OJiV7AXOSDjb4sLtVUoTkCPTVxumktZZUgALBAbQnBpPeTtWfzvwqBunsXi41Zp5N6WjSrf69s6c9/M9PGoyjQ==} + engines: {node: '>=18'} + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -3389,6 +3408,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -3913,6 +3935,10 @@ packages: peerDependencies: typescript: ^5.9.3 + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3941,6 +3967,36 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -5516,6 +5572,10 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -5976,6 +6036,12 @@ packages: peerDependencies: valibot: ^1.2.0 + '@valora/viem-account-hsm-gcp@1.2.16': + resolution: {integrity: sha512-JaxVDEmUHKkJ2ox4yt/4GxKcU1NtHujxW7cux9fHC6rRajdPjxl3HBWwPZ3yqhMFSxfvfdicXuCQhDmqeAXlaw==} + engines: {node: '>=20'} + peerDependencies: + viem: ^2.9.20 + '@vitest/coverage-v8@4.0.17': resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} peerDependencies: @@ -6531,6 +6597,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -6595,6 +6664,9 @@ packages: resolution: {integrity: sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w==} engines: {node: '>= 0.4.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -7279,6 +7351,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -7589,9 +7665,15 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + edit-json-file@1.8.1: resolution: {integrity: sha512-x8L381+GwqxQejPipwrUZIyAg5gDQ9tLVwiETOspgXiaQztLsrOm7luBW5+Pe31aNezuzDY79YyzF+7viCRPXA==} @@ -8475,6 +8557,10 @@ packages: fengari@0.1.5: resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -8603,6 +8689,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -8680,6 +8770,14 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} deprecated: This package is no longer supported. + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -8757,6 +8855,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -8818,6 +8920,18 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-gax@5.0.6: + resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -8846,6 +8960,10 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + h3@1.15.5: resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} @@ -9059,6 +9177,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -9471,6 +9593,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -9576,6 +9701,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -9634,6 +9762,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.27: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true @@ -9805,6 +9939,9 @@ packages: lodash._pickbycallback@3.0.0: resolution: {integrity: sha512-DVP27YmN0lB+j/Tgd/+gtxfmW/XihgWpQpHptBuwyp2fD9zEBRwwcnw6Qej16LUV8LRFuTqyoc0i6ON97d/C5w==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -9857,6 +9994,9 @@ packages: resolution: {integrity: sha512-p1Ow0C2dDJYaQBhRHt+HVMP6ELuBm4jYSYNHPMfz0J5wJ9qA6/7oBOlBZBfT1InqguTYcvJzNea5FItDxTcbyw==} hasBin: true + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -10482,6 +10622,11 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -10494,6 +10639,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -10579,6 +10728,10 @@ packages: object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -11098,6 +11251,14 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + protolint@0.56.4: resolution: {integrity: sha512-wrRXaiyNDSzYJ7LBcDnwkWnsRi1uNlFleQp90CsBsh2YvVJEwKXr/c/W9MRYdt+ScpEo8Eg3d60QmVhsZBJu2w==} hasBin: true @@ -11620,6 +11781,10 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + retry-request@8.0.2: + resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -11629,6 +11794,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -12005,9 +12174,15 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -12095,6 +12270,9 @@ packages: structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + sturdy-websocket@0.2.1: resolution: {integrity: sha512-NnzSOEKyv4I83qbuKw9ROtJrrT6Z/Xt7I0HiP/e6H6GnpeTDvzwGIGeJ8slai+VwODSHQDooW2CAilJwT9SpRg==} @@ -12188,6 +12366,10 @@ packages: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + temp-dir@1.0.0: resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} engines: {node: '>=4'} @@ -12916,6 +13098,10 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: resolution: {tarball: https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda} version: 0.0.0 @@ -15897,6 +16083,24 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google-cloud/kms@5.3.0': + dependencies: + google-gax: 5.0.6 + transitivePeerDependencies: + - supports-color + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -16270,6 +16474,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@jsdevtools/ono@7.1.3': {} '@levischuck/tiny-cbor@0.2.11': {} @@ -17031,6 +17237,9 @@ snapshots: esquery: 1.7.0 typescript: 5.9.3 + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.9': {} @@ -17056,6 +17265,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -17602,7 +17834,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -19354,6 +19586,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tootallnate/once@2.0.0': {} + '@trysound/sax@0.2.0': {} '@tybys/wasm-util@0.10.1': @@ -19875,6 +20109,15 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@valora/viem-account-hsm-gcp@1.2.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + dependencies: + '@google-cloud/kms': 5.3.0 + '@noble/curves': 1.9.7 + asn1js: 3.0.7 + viem: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.17(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -20711,6 +20954,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + birecord@0.1.1: {} bl@4.1.0: @@ -20799,6 +21044,8 @@ snapshots: once: 1.4.0 sliced: 1.0.1 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -21558,6 +21805,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -21751,8 +22000,19 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + edit-json-file@1.8.1: dependencies: find-value: 1.0.13 @@ -23101,6 +23361,11 @@ snapshots: sprintf-js: 1.1.3 tmp: 0.2.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} figures@3.2.0: @@ -23237,6 +23502,10 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -23326,6 +23595,23 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensequence@8.0.8: {} @@ -23393,6 +23679,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -23472,6 +23767,36 @@ snapshots: globrex@0.1.2: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-gax@5.0.6: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.5.4 + retry-request: 8.0.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} got-fetch@5.1.10(got@12.6.1): @@ -23503,6 +23828,13 @@ snapshots: graphql@16.12.0: {} + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + h3@1.15.5: dependencies: cookie-es: 1.2.2 @@ -23815,6 +24147,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -24228,6 +24568,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -24363,6 +24709,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -24418,6 +24768,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.27: dependencies: commander: 8.3.0 @@ -24565,6 +24926,8 @@ snapshots: lodash._basefor: 3.0.3 lodash.keysin: 3.0.8 + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.defaults@4.2.0: {} @@ -24614,6 +24977,8 @@ snapshots: split: 0.2.10 through: 2.3.8 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -25777,12 +26142,20 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.3.3: {} node-gyp-build-optional-packages@5.2.2: @@ -25899,6 +26272,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -26478,6 +26853,25 @@ snapshots: proto-list@1.2.4: {} + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.5.4 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 25.0.9 + long: 5.3.2 + protolint@0.56.4: dependencies: got: 12.6.1 @@ -27149,12 +27543,23 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + retry-request@8.0.2: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.0 + transitivePeerDependencies: + - supports-color + reusify@1.1.0: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + robust-predicates@3.0.2: {} rollup-pluginutils@2.8.2: @@ -27668,8 +28073,14 @@ snapshots: stream-buffers@2.2.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + stream-replace-string@2.0.0: {} + stream-shift@1.0.3: {} + strict-uri-encode@2.0.0: {} string-ts@2.3.1: {} @@ -27780,6 +28191,8 @@ snapshots: structured-headers@0.4.1: {} + stubs@3.0.0: {} + sturdy-websocket@0.2.1: optional: true @@ -27950,6 +28363,15 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teeny-request@10.1.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + temp-dir@1.0.0: {} temp-dir@2.0.0: {} @@ -28611,6 +29033,8 @@ snapshots: web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} + webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: {} webauthn-p256@0.0.10: diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index d7b7f8501..76dff5bf7 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -161,10 +161,10 @@ export default new Hono().post( await pokeAccountAssets(account, { ignore: [`NotAllowed(${account})`] }).catch((error: unknown) => captureException(error), ); - autoCredit(account) - .then(async (auto) => { - span.setAttribute("exa.autoCredit", auto); - if (!auto) return; + try { + const auto = await autoCredit(account); + span.setAttribute("exa.autoCredit", auto); + if (auto) { const credential = await database.query.credentials.findFirst({ where: eq(credentials.account, account), columns: {}, @@ -175,20 +175,25 @@ export default new Hono().post( }, }, }); - if (!credential || credential.cards.length === 0) return; - const card = credential.cards[0]; - span.setAttribute("exa.card", card?.id); - if (card?.mode !== 0) return; - await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id)); - span.setAttribute("exa.mode", 1); - sendPushNotification({ - userId: account, - headings: { en: "Card mode changed" }, - contents: { en: "Credit mode activated" }, - }).catch((error: unknown) => captureException(error)); - }) - .catch((error: unknown) => captureException(error)); - span.setStatus({ code: SPAN_STATUS_OK }); + if (credential && credential.cards.length > 0) { + const card = credential.cards[0]; + span.setAttribute("exa.card", card?.id); + if (card?.mode === 0) { + await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id)); + span.setAttribute("exa.mode", 1); + sendPushNotification({ + userId: account, + headings: { en: "Card mode changed" }, + contents: { en: "Credit mode activated" }, + }).catch((error: unknown) => captureException(error)); + } + } + } + span.setStatus({ code: SPAN_STATUS_OK }); + } catch (error: unknown) { + captureException(error); + span.setStatus({ code: SPAN_STATUS_ERROR, message: "autoCredit_failed" }); + } }, ), ), diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 1231be1e2..3ca82abdc 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -2,7 +2,6 @@ import { vValidator } from "@hono/valibot-validator"; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, setContext, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import * as v from "valibot"; import { array, check, @@ -21,9 +20,11 @@ import { union, } from "valibot"; +import { firewallAbi, firewallAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; +import allower from "../utils/allower"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { @@ -281,6 +282,20 @@ export default new Hono().post( if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200); } + if (firewallAddress) { + try { + const allowerClient = await allower(); + await allowerClient.exaSend( + { name: "exa.firewall", op: "exa.firewall", attributes: { account: credential.account, personaShareToken } }, + { address: firewallAddress, functionName: "allow", args: [credential.account, true], abi: firewallAbi }, + { ignore: [`AlreadyAllowed(${credential.account})`] }, + ); + } catch (error: unknown) { + captureException(error, { level: "error" }); + return c.json({ code: "firewall error" }, 500); + } + } + // TODO implement error handling to return 200 if event should not be retried const { id } = await createUser({ accountPurpose: fields.accountPurpose.value, @@ -297,13 +312,6 @@ export default new Hono().post( getActiveSpan()?.setAttributes({ "exa.pandaId": id }); setContext("persona", { inquiryId: personaShareToken, pandaId: id }); - Promise.resolve() - .then(async () => { - const accountAddress = v.parse(Address, credential.account); - await pokeAccountAssets(accountAddress, { ignore: [`NotAllowed(${accountAddress})`] }); - }) - .catch((error: unknown) => captureException(error)); - const accountParsed = safeParse(Address, credential.account); if (accountParsed.success) { addCapita({ @@ -324,6 +332,15 @@ export default new Hono().post( level: "error", }); } + + if (accountParsed.success) { + pokeAccountAssets(accountParsed.output, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }).catch(captureException); + } addDocument(referenceId, { id_class: { value: fields.identificationClass.value }, id_number: { value: fields.identificationNumber.value }, diff --git a/server/index.ts b/server/index.ts index 84e4e02c7..216520204 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,3 +1,5 @@ +import "./utils/gcp"; + import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { captureException, close as closeSentry } from "@sentry/node"; diff --git a/server/package.json b/server/package.json index 064cd4001..14c5375c8 100644 --- a/server/package.json +++ b/server/package.json @@ -32,6 +32,7 @@ "dependencies": { "@account-kit/infra": "catalog:", "@exactly/lib": "^0.1.0", + "@google-cloud/kms": "^5.3.0", "@hono/node-server": "^1.19.9", "@hono/sentry": "^1.2.2", "@hono/valibot-validator": "^0.5.3", @@ -43,6 +44,7 @@ "@simplewebauthn/server": "^13.2.2", "@types/debug": "^4.1.12", "@valibot/to-json-schema": "^1.5.0", + "@valora/viem-account-hsm-gcp": "^1.2.16", "async-mutex": "^0.5.0", "bullmq": "^5.66.5", "debug": "^4.4.3", diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 177266f36..a41670b51 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -24,6 +24,22 @@ import * as sardine from "../../utils/sardine"; const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); +const mockExaSend = vi.fn().mockResolvedValue({}); + +vi.mock("../../utils/allower", () => ({ + default: vi.fn(() => + Promise.resolve({ + exaSend: mockExaSend, + }), + ), +})); +vi.mock("@exactly/common/generated/chain", async () => { + const actual = await vi.importActual("@exactly/common/generated/chain"); + return { + ...actual, + firewallAddress: "0x1234567890123456789012345678901234567890", + }; +}); describe("with reference", () => { const referenceId = "hook-persona"; @@ -493,9 +509,6 @@ describe("persona hook", () => { abi: expect.any(Array) as unknown[], functionName: "pokeETH", }, - { - ignore: [`NotAllowed(${account})`], - }, ); expect(exaSendSpy).toHaveBeenNthCalledWith( 2, @@ -510,9 +523,6 @@ describe("persona hook", () => { functionName: "poke", args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"], }, - { - ignore: [`NotAllowed(${account})`], - }, ); }); @@ -569,9 +579,6 @@ describe("persona hook", () => { abi: expect.any(Array) as unknown[], functionName: "pokeETH", }, - { - ignore: [`NotAllowed(${account})`], - }, ); }); @@ -633,9 +640,6 @@ describe("persona hook", () => { abi: expect.any(Array) as unknown[], functionName: "pokeETH", }, - { - ignore: [`NotAllowed(${account})`], - }, ); expect(exaSendSpy).toHaveBeenCalledWith( { @@ -649,9 +653,6 @@ describe("persona hook", () => { functionName: "poke", args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"], }, - { - ignore: [`NotAllowed(${account})`], - }, ); }); @@ -701,6 +702,36 @@ describe("persona hook", () => { { timeout: 500, interval: 50 }, ); }); + + it("returns error when firewall call fails", async () => { + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + mockExaSend.mockRejectedValueOnce(new Error("Firewall error")); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ code: "firewall error" }); + }); }); describe("manteca template", () => { diff --git a/server/test/utils/allower.test.ts b/server/test/utils/allower.test.ts new file mode 100644 index 000000000..71c54d57e --- /dev/null +++ b/server/test/utils/allower.test.ts @@ -0,0 +1,90 @@ +import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getAccount } from "../../utils/allower"; + +import type * as accounts from "viem/accounts"; + +function mockGcp() { + return { + GOOGLE_APPLICATION_CREDENTIALS: "/tmp/gcp-service-account.json", + hasCredentials: vi.fn().mockResolvedValue(true), + initializeGcpCredentials: vi.fn().mockImplementation(() => Promise.resolve()), + validateGcpConfiguration: vi.fn(), + isRetryableKmsError: vi.fn().mockReturnValue(false), + trackKmsOperation: vi.fn(), + }; +} + +function mockViemHsm() { + return { + gcpHsmToAccount: vi.fn().mockResolvedValue({ address: "0xGCPAccount", source: "gcpHsm", type: "local" }), + }; +} + +async function mockViemAccounts(importOriginal: () => Promise) { + const actual = await importOriginal(); + return { + ...actual, + privateKeyToAccount: vi.fn(actual.privateKeyToAccount), + }; +} + +function mockKms() { + return { + KeyManagementServiceClient: vi.fn(function MockKeyManagementServiceClient() { + return {}; + }), + }; +} + +vi.mock("../../utils/gcp", mockGcp); + +vi.mock("@valora/viem-account-hsm-gcp", mockViemHsm); + +vi.mock("viem/accounts", mockViemAccounts); + +vi.mock("@google-cloud/kms", mockKms); + +describe("getAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + // cspell:ignore unstub + vi.unstubAllEnvs(); + }); + + afterEach(() => { + // cspell:ignore unstub + vi.unstubAllEnvs(); + }); + + it("uses Private Key account when GCP_PROJECT_ID is missing", async () => { + const privateKey = generatePrivateKey(); + vi.stubEnv("GCP_PROJECT_ID", ""); + vi.stubEnv("KEEPER_PRIVATE_KEY", privateKey); + + const account = await getAccount(); + + expect(gcpHsmToAccount).not.toHaveBeenCalled(); + expect(privateKeyToAccount).toHaveBeenCalled(); + expect(account.address).toBe(privateKeyToAccount(privateKey).address); + expect(account.nonceManager).toBeDefined(); + }); + + it("uses GCP HSM account when GCP_PROJECT_ID is present", async () => { + vi.stubEnv("GCP_PROJECT_ID", "test-project"); + vi.stubEnv("GCP_KMS_KEY_RING", "test-ring"); + vi.stubEnv("GCP_KMS_KEY_VERSION", "1"); + + const account = await getAccount(); + + expect(gcpHsmToAccount).toHaveBeenCalledWith( + expect.objectContaining({ + hsmKeyVersion: + "projects/test-project/locations/us-west2/keyRings/test-ring/cryptoKeys/allower/cryptoKeyVersions/1", + }), + ); + expect(account.nonceManager).toBeDefined(); + }); +}); diff --git a/server/test/utils/gcp.test.ts b/server/test/utils/gcp.test.ts new file mode 100644 index 000000000..73f448631 --- /dev/null +++ b/server/test/utils/gcp.test.ts @@ -0,0 +1,50 @@ +import { access, writeFile } from "node:fs/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { initializeGcpCredentials, resetGcpInitialization } from "../../utils/gcp"; + +vi.mock("node:fs/promises", () => ({ + writeFile: vi.fn(), + access: vi.fn(), +})); + +const mockWriteFile = vi.mocked(writeFile); +const mockAccess = vi.mocked(access); + +describe("gcp credentials security", () => { + beforeEach(() => { + vi.clearAllMocks(); + // cspell:ignore unstub + vi.unstubAllEnvs(); + resetGcpInitialization(); + mockAccess.mockRejectedValue(new Error("File not found")); + }); + + it("creates credentials file with secure permissions (0o600)", async () => { + vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K"); + + await initializeGcpCredentials(); + + expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), { + mode: 0o600, + }); + }); + + it("throws error when GCP_PROJECT_ID is set but GCP_BASE64_JSON is missing", async () => { + vi.stubEnv("GCP_PROJECT_ID", "test-project"); + vi.stubEnv("GCP_BASE64_JSON", ""); + + await expect(initializeGcpCredentials()).rejects.toThrow( + "gcp project configured but GCP_BASE64_JSON environment variable is not set", + ); + }); + + it("returns early when credentials already exist", async () => { + vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K"); + mockAccess.mockResolvedValue(); + + await initializeGcpCredentials(); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/server/utils/allower.ts b/server/utils/allower.ts new file mode 100644 index 000000000..1e545b1f2 --- /dev/null +++ b/server/utils/allower.ts @@ -0,0 +1,104 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { captureMessage } from "@sentry/node"; +import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; +import { parse } from "valibot"; +import { createWalletClient, http, withRetry } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; +import chain from "@exactly/common/generated/chain"; +import { Hash } from "@exactly/common/validation"; + +import { + GOOGLE_APPLICATION_CREDENTIALS, + hasCredentials, + initializeGcpCredentials, + isRetryableKmsError, + trackKmsOperation, + validateGcpConfiguration, +} from "./gcp"; +import { extender } from "./keeper"; +import nonceManager from "./nonceManager"; +import { captureRequests, Requests } from "./publicClient"; + +import type { LocalAccount } from "viem"; + +const gcpKmsKeyName = "allower"; + +if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); +const rpcUrl = chain.rpcUrls.alchemy.http[0]; + +validateGcpConfiguration(); + +export async function getAccount(): Promise { + if (process.env.GCP_PROJECT_ID) { + const projectId = process.env.GCP_PROJECT_ID; + + if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("missing gcp kms key version"); + const version = process.env.GCP_KMS_KEY_VERSION; + + if (!process.env.GCP_KMS_KEY_RING) throw new Error("missing gcp kms key ring"); + const gcpKmsKeyRing = process.env.GCP_KMS_KEY_RING; + + await initializeGcpCredentials(); + + if (!(await hasCredentials())) { + throw new Error( + `gcp credentials file not found at ${GOOGLE_APPLICATION_CREDENTIALS}. ` + + `ensure GCP_BASE64_JSON environment variable is set.`, + ); + } + + const kmsClient = new KeyManagementServiceClient({ + keyFilename: GOOGLE_APPLICATION_CREDENTIALS, + }); + + try { + const account = await withRetry( + () => + gcpHsmToAccount({ + hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${gcpKmsKeyRing}/cryptoKeys/${gcpKmsKeyName}/cryptoKeyVersions/${version}`, + kmsClient, + }), + { + delay: 2000, + retryCount: 3, + shouldRetry: ({ error }) => isRetryableKmsError(error), + }, + ); + + trackKmsOperation("get_account", true); + account.nonceManager = nonceManager; + return account; + } catch (error) { + trackKmsOperation("get_account", false, error); + throw error; + } + } else { + if (!process.env.KEEPER_PRIVATE_KEY) throw new Error("missing keeper private key"); + return privateKeyToAccount(parse(Hash, process.env.KEEPER_PRIVATE_KEY, { message: "invalid keeper private key" }), { + nonceManager, + }); + } +} + +export default async function createAllower() { + const account = await getAccount(); + return createWalletClient({ + chain, + transport: http(`${rpcUrl}/${alchemyAPIKey}`, { + batch: true, + async onFetchRequest(request) { + try { + captureRequests(parse(Requests, await request.clone().json())); + } catch (error: unknown) { + captureMessage("failed to parse or capture rpc requests", { + level: "error", + extra: { error }, + }); + } + }, + }), + account, + }).extend(extender); +} diff --git a/server/utils/gcp.ts b/server/utils/gcp.ts new file mode 100644 index 000000000..dbde645c1 --- /dev/null +++ b/server/utils/gcp.ts @@ -0,0 +1,143 @@ +import { captureException, captureMessage, withScope } from "@sentry/node"; +import { access, writeFile } from "node:fs/promises"; + +// tokens/credentials are base64-encoded multiple times by deployment tooling +const DECODING_ITERATIONS = 3; +export const GOOGLE_APPLICATION_CREDENTIALS = "/tmp/gcp-service-account.json"; + +// this file is necessary because of limitations at runtime to mount volumes to reference +// the GOOGLE_APPLICATION_CREDENTIALS environment variable. we encode the service account's contents +// into a variable and dump those contents to the path set in GOOGLE_APPLICATION_CREDENTIALS +// so the loading can work normally. this will ensure consistency across different environments. +let initializationPromise: null | Promise = null; + +// for testing only - reset the initialization state +export function resetGcpInitialization() { + initializationPromise = null; +} + +export async function initializeGcpCredentials() { + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + if (await hasCredentials()) { + return; + } + + if (process.env.GCP_BASE64_JSON) { + let json = process.env.GCP_BASE64_JSON; + for (let index = 0; index < DECODING_ITERATIONS; index++) { + json = Buffer.from(json, "base64").toString("utf8"); + } + await writeFile(GOOGLE_APPLICATION_CREDENTIALS, json, { mode: 0o600 }); + } else if (process.env.GCP_PROJECT_ID) { + throw new Error( + "gcp project configured but GCP_BASE64_JSON environment variable is not set. " + + "this is required to initialize gcp kms credentials.", + ); + } + })().catch((error: unknown) => { + initializationPromise = null; + throw error; + }); + + return initializationPromise; +} + +export async function hasCredentials(): Promise { + try { + await access(GOOGLE_APPLICATION_CREDENTIALS); + return true; + } catch { + return false; + } +} + +export function validateGcpConfiguration() { + if (!process.env.GCP_PROJECT_ID) return; + + const errors: string[] = []; + + if (!process.env.GCP_KMS_KEY_RING) { + errors.push("GCP_KMS_KEY_RING is required when using GCP KMS"); + } + + if (!process.env.GCP_KMS_KEY_VERSION) { + errors.push("GCP_KMS_KEY_VERSION is required when using GCP KMS"); + } + + if (!process.env.GCP_BASE64_JSON) { + errors.push("GCP_BASE64_JSON is required when using GCP KMS"); + } + + if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(process.env.GCP_PROJECT_ID)) { + errors.push("GCP_PROJECT_ID must be a valid GCP project ID format"); + } + + if (process.env.GCP_KMS_KEY_VERSION && !/^\d+$/.test(process.env.GCP_KMS_KEY_VERSION)) { + errors.push("GCP_KMS_KEY_VERSION must be a numeric version number"); + } + + if (errors.length > 0) { + throw new Error(`GCP KMS configuration errors:\n${errors.map((error) => ` - ${error}`).join("\n")}`); + } +} + +export function isRetryableKmsError(error: unknown): boolean { + if (error instanceof Error) { + if ("code" in error && typeof error.code === "number") { + return ( + error.code === 14 || // UNAVAILABLE + error.code === 4 || // DEADLINE_EXCEEDED + error.code === 13 || // INTERNAL + error.code === 8 // RESOURCE_EXHAUSTED + ); + } + + if ("code" in error && typeof error.code === "string") { + return ( + error.code === "UNAVAILABLE" || + error.code === "DEADLINE_EXCEEDED" || + error.code === "INTERNAL" || + error.code === "RESOURCE_EXHAUSTED" + ); + } + + const message = error.message.toLowerCase(); + return ( + message.includes("network") || + message.includes("timeout") || + message.includes("unavailable") || + message.includes("internal error") || + message.includes("service unavailable") || + error.name === "NetworkError" || + error.name === "TimeoutError" + ); + } + return false; +} + +export function trackKmsOperation(operation: string, success: boolean, error?: unknown) { + withScope((scope) => { + scope.setTag("kms.operation.type", operation); + scope.setTag("kms.operation.success", String(success)); + + if (success) { + scope.setTag("kms.operation.result", "success"); + } else { + scope.setTag("kms.operation.result", "failure"); + if (error instanceof Error) { + captureException(error, { + level: "error", + }); + } else { + captureMessage(String(error), { + level: "error", + extra: { originalError: error }, + }); + } + } + }); +} diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 8bb936962..8878144c6 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -15,9 +15,9 @@ import { WaitForTransactionReceiptTimeoutError, withRetry, type HttpTransport, + type LocalAccount, type MaybePromise, type Prettify, - type PrivateKeyAccount, type TransactionReceipt, type WalletClient, type WriteContractParameters, @@ -50,7 +50,7 @@ export default createWalletClient({ ), }).extend(extender); -export function extender(keeper: WalletClient) { +export function extender(keeper: WalletClient) { return { exaSend: async ( spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, diff --git a/server/utils/persona.ts b/server/utils/persona.ts index 055e8a45d..958520966 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -1,6 +1,7 @@ import { vValidator } from "@hono/valibot-validator"; import { captureEvent, captureException, setContext } from "@sentry/core"; import { createHmac, timingSafeEqual } from "node:crypto"; +import * as v from "valibot"; import { array, boolean, @@ -17,7 +18,6 @@ import { type BaseSchema, type InferOutput, } from "valibot"; -import * as v from "valibot"; import { erc20Abi, withRetry } from "viem"; import chain, { @@ -33,6 +33,7 @@ import { Address } from "@exactly/common/validation"; import appOrigin from "./appOrigin"; import keeper from "./keeper"; +import { sendPushNotification } from "./onesignal"; import publicClient from "./publicClient"; import { DevelopmentChainIds } from "./ramps/shared"; @@ -51,9 +52,11 @@ const authorization = `Bearer ${process.env.PERSONA_API_KEY}`; const baseURL = process.env.PERSONA_URL; const webhookSecret = process.env.PERSONA_WEBHOOK_SECRET; -export async function pokeAccountAssets(accountAddress: Address, options?: { ignore?: string[] }) { +export async function pokeAccountAssets( + accountAddress: Address, + options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } }, +) { const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; - const marketsByAsset = await withRetry( () => publicClient.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }), { @@ -81,18 +84,25 @@ export async function pokeAccountAssets(accountAddress: Address, options?: { ign }), Promise.all( [...marketsByAsset.entries()].map(async ([asset, market]) => { - try { - const balance = await publicClient.readContract({ - address: asset, - functionName: "balanceOf", - args: [accountAddress], - abi: erc20Abi, - }); - return { asset, market, balance }; - } catch (error) { - captureException(error, { level: "error" }); - return { asset, market, balance: 0n }; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const balance = await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }); + return { asset, market, balance }; + } catch (error) { + captureException(error, { level: "error" }); + if (attempt === maxAttempts) { + return { asset, market, balance: 0n }; + } + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); + } } + return { asset, market, balance: 0n }; }), ), ]); @@ -149,6 +159,16 @@ export async function pokeAccountAssets(accountAddress: Address, options?: { ign for (const result of results) { if (result.status === "rejected") captureException(result.reason); } + + const successCount = results.filter((result) => result.status === "fulfilled").length; + + if (options?.notification && successCount > 0) { + sendPushNotification({ + userId: accountAddress, + headings: options.notification.headings, + contents: options.notification.contents, + }).catch((error: unknown) => captureException(error)); + } } export async function getInquiry(referenceId: string, templateId: string) { diff --git a/server/vitest.config.mts b/server/vitest.config.mts index eaae9e1b4..831d43012 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -52,6 +52,10 @@ VuNOZKwaXFtqgA== SARDINE_API_KEY: "sardine", SARDINE_API_URL: "https://api.sardine.ai", SEGMENT_WRITE_KEY: "segment", + GCP_KMS_KEY_RING: "op-sepolia", + GCP_KMS_KEY_VERSION: "1", + GCP_PROJECT_ID: "exa-dev", + GCP_BASE64_JSON: "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K", ...(env.NODE_ENV === "e2e" && { APP_DOMAIN: "localhost", DEBUG: "exa:*" }), }, ...(env.NODE_ENV === "e2e" && { From 78b4c646bdc385d9e3f06e12765298bfc8d0a11a Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 2 Feb 2026 16:08:18 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20chain=20mock?= =?UTF-8?q?=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/kind-ants-beam.md | 5 +++++ server/test/utils/manteca.test.ts | 9 ++++++++- server/test/utils/persona.test.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/kind-ants-beam.md diff --git a/.changeset/kind-ants-beam.md b/.changeset/kind-ants-beam.md new file mode 100644 index 000000000..437312714 --- /dev/null +++ b/.changeset/kind-ants-beam.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix chain mock in tests diff --git a/server/test/utils/manteca.test.ts b/server/test/utils/manteca.test.ts index 7eec89575..688ae2cf5 100644 --- a/server/test/utils/manteca.test.ts +++ b/server/test/utils/manteca.test.ts @@ -11,7 +11,14 @@ import * as persona from "../../utils/persona"; import * as manteca from "../../utils/ramps/manteca"; import { ErrorCodes } from "../../utils/ramps/manteca"; -const chainMock = vi.hoisted(() => ({ id: 10 })); +const chainMock = vi.hoisted(() => ({ + id: 10, + rpcUrls: { + alchemy: { + http: ["https://mocked-rpc-url"], + }, + }, +})); vi.mock("@exactly/common/generated/chain", () => ({ default: chainMock, diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index c531fbb12..82e8c397d 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -7,7 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr import * as persona from "../../utils/persona"; -const chainMock = vi.hoisted(() => ({ id: 10 })); +const chainMock = vi.hoisted(() => ({ + id: 10, + rpcUrls: { + alchemy: { + http: ["https://mocked-rpc-url"], + }, + }, +})); vi.mock("@exactly/common/generated/chain", () => ({ default: chainMock,